async_ids が矛盾条件で Promise を返さない不具合を修正
async_ids が常に ActiveRecord::Promise を返すという契約が、矛盾した where 条件 下で配列を返すバグを解消し、非同期クエリの一貫性を確保した。
背景
async_ids は非同期に ID の配列を取得し、結果を ActiveRecord::Promise でラップして返すことが公式に保証されている。しかし、where(id: []) など矛盾した where 句 が存在する場合、内部で ActiveRecord::Result.empty が async: オプションなしで生成され、then が同期的に実行されて配列が直接返されていた。
この挙動は Promise の契約に反し、Model.where(id: []).async_ids が [](Array)を返すという実装上の不整合を引き起こした。結果として、非同期クエリを期待してチェーンした .then 呼び出しが同期実行になるため、呼び出し側で型チェックやエラーハンドリングが機能しないリスクが生じた。
この問題は pluck メソッドでは既に ActiveRecord::Result.empty(async: @async) が使用されており、ids メソッドだけが例外的に非同期情報を失っていたことが根本原因と指摘された。
技術的な変更
activerecord/lib/active_record/relation/calculations.rb の ids メソッド において、矛盾した where 句を検出した際に生成する空結果を ActiveRecord::Result.empty(async: @async) に変更した。これにより、ids と pluck が同一の非同期オプション伝搬ロジックを共有することになる。
@@ -396,7 +396,7 @@ def ids
relation.select_values = columns
result = if relation.where_clause.contradiction?
- ActiveRecord::Result.empty
+ ActiveRecord::Result.empty(async: @async)
else
skip_query_cache_if_necessary do
model.with_connection do |c|
テストスイートにも同様の非同期動作を検証するケースが追加された。`activerecord/test/cases/calculations_test.rb` では、矛盾したスコープに対して `async_ids` が期待通り **`ActiveRecord::Promise`** を返すことを `assert_async_equal` で確認している。
```ruby:activerecord/test/cases/calculations_test.rb
@@
assert_queries_count(0) do
assert_equal company_ids, Company.where(id: empty_scope_ids).ids
end
+ assert_async_equal company_ids, Company.where(id: empty_scope_ids).async_ids
end
## 設計判断
**`ids` メソッド** が `pluck` と同一の空結果生成ロジックを採用することで、非同期クエリ全体の振る舞いを統一したという設計判断が取られた。これにより、個別メソッドごとに `async:` オプションの有無を意識する必要がなくなり、コードベースのシンプルさと保守性が向上した。
新しいキーやラッパーメソッドを導入せず、既存の `@async` フラグを活用した最小限の変更に留めた点は、後方互換性を保ちつつバグ修正を実現した実践的なトレードオフと言える。
## まとめ
`async_ids` が矛盾した where 条件下でも常に **`ActiveRecord::Promise`** を返すようになり、非同期クエリの API 契約が一貫化した。`ids` メソッドに `async:` オプションを付与した空結果生成を追加しただけで、既存コードへの影響はなく、テストも補強された。