`create_or_find_by` のリトライ時にWHERE条件が重複するバグを修正
create_or_find_by が RecordNotUnique エラーをキャッチしてリトライする際、既存のスコープ条件と検索条件が重複して RecordNotFound エラーが発生するバグが修正されました。where を rewhere に、find_by! を take! に置き換えることで、スコープ汚染を防ぎます。
背景
create_or_find_by のリトライロジックに、既存スコープと検索条件が競合するバグが存在していました。#57192 で報告されたこの問題は、CollectionProxy などで事前にスコープが設定されている場合に顕在化します。
具体的には、where(from_slug: "new-slug") でスコープされたリレーションに対して create_or_find_by(from_slug: "old-slug") を呼び出した場合、リトライ時のクエリが WHERE from_slug = 'new-slug' AND from_slug = 'old-slug' という矛盾した条件を生成していました。old-slug のレコードが存在しているにもかかわらず、この矛盾条件により find_by! が一致するレコードを見つけられず、RecordNotFound エラーが発生していました。
この状況は has_many の CollectionProxy を経由して create_or_find_by を呼び出す際に特に発生しやすく、実際のアプリケーションでのユースケースで問題となっていました。
技術的な変更
activerecord/lib/active_record/relation.rb の create_or_find_by および create_or_find_by! のリトライパスが変更されました。変更は where(attributes).lock.find_by!(attributes) を rewhere(attributes).lock.take! に置き換える2行のみです。
変更前:
rescue ActiveRecord::RecordNotUnique
if connection.transaction_open?
where(attributes).lock.find_by!(attributes)
else
find_by!(attributes)
end
変更後:
rescue ActiveRecord::RecordNotUnique
if connection.transaction_open?
rewhere(attributes).lock.take!
else
find_by!(attributes)
end
where → rewhere への変更が核心です。where は既存のスコープ条件を保持したまま条件を追加しますが、rewhere は同一カラムへの既存条件を上書きします。これにより、nick: "alice" のようなスコープが設定済みのリレーションに対して rewhere(nick: "bob") を呼ぶと、nick: "alice" の条件が nick: "bob" に置き換わり、矛盾した条件の生成を防ぎます。
find_by!(attributes) → take! への変更も重要です。rewhere(attributes) によって検索条件はリレーションのスコープに組み込まれているため、改めて find_by!(attributes) に同じ条件を渡す必要がなくなりました。take! はスコープに設定されたWHERE条件をそのまま使用してレコードを1件取得します。
テストでは test_find_or_create_by_race_condition のスタブ対象が :find_by! から :take! に変更され、新たに test_create_or_find_by_with_polluted_scope と test_create_or_find_by_bang_with_polluted_scope の2つのテストケースが追加されました。これらは where(nick: "alice") でスコープされたリレーションから create_or_find_by(nick: "bob") を呼び出し、正しくレコードが返ることを検証します。
設計判断
rewhere を使った条件の上書きというアプローチが採用されました。
リトライ時のクエリ構築において、create_or_find_by に渡された attributes は「このレコードを一意に識別する条件」です。呼び出し元のスコープに同一カラムへの条件が含まれている場合、それは create_or_find_by の意図と競合します。rewhere によって attributes のカラムに関する既存条件を上書きすることで、「渡された属性でレコードを特定する」という意図を確実に実現しています。
トランザクション外のパス(else 節)の find_by!(attributes) は変更されていません。これはトランザクション外ではロックが不要なため、既存の実装がそのまま維持されています。トランザクション内のパスのみを最小限に変更することで、影響範囲を絞り込んだ修正となっています。
まとめ
本PRは、create_or_find_by のリトライロジックにおける where と find_by! の組み合わせが引き起こすスコープ汚染を、rewhere と take! への置き換えで根本的に解消した変更です。変更行数は2行と最小限ながら、CollectionProxy 経由のユースケースで発生していた RecordNotFound エラーを確実に修正し、重複条件の生成というアンチパターンを排除しています。