`async_count` が `limit(0)` のとき Promise を返さない不具合を修正
Post.limit(0).async_count が ActiveRecord::Promise ではなく生の整数 0 を返していたバグが修正されました。これにより、async_count の戻り値の型が常に Promise であるという契約が保たれます。
背景
execute_simple_calculation の limit(0) 高速パスが、非同期処理のラップより先に早期リターンしていたことが原因です。async_count は ActiveRecord::Promise を返すと文書化されていますが、limit_value == 0 の条件を満たすと整数 0 がそのまま返されていました。その結果、呼び出し元が Integer を受け取るケースが生じ、value・then・pending? といった Promise のメソッドが使えない状態になっていました。
PR 本文によれば、この問題は矛盾する非同期集計(contradicted async aggregations)の早期リターンで修正されたケースと同種のものです。limit(0) という別の高速パスにも同じ不整合が残っていた形です。
技術的な変更
execute_simple_calculation の limit(0) 早期リターン箇所が、非同期モードの場合に Promise::Complete を返すよう1行修正されました。
変更前:
return 0 if limit_value == 0
変更後:
return @async ? Promise::Complete.new(0) : 0 if limit_value == 0
@async フラグで同期・非同期を分岐し、同期の count は従来通り整数 0 を即時返します。非同期の async_count では Promise::Complete.new(0) を返すことで、値が確定済みの Promise としてラップされます。これにより、DBへのクエリを発行しないというパフォーマンス上の高速パスは維持しつつ、インターフェースの一貫性が確保されます。
テストは既存の test_count_should_shortcut_with_limit_zero を拡張し、assert_no_queries ブロック内に assert_async_equal 0, accounts.async_count を追加しています。ノークエリであるという期待はそのまま維持されており、Promise の値が 0 であることも同時に検証します。
設計判断
既存の高速パスを壊さずに型の契約を満たすという方針が採られました。
Promise::Complete は値が既に確定している Promise を表すクラスです。DB アクセスなしに結果が自明な limit(0) のケースでは、非同期処理をスケジュールせず即座に完了済みの Promise を構築することが、「ノークエリ」という最適化と「必ず Promise を返す」というインターフェース契約の両立に最も適しています。@async フラグによる1行の三項演算子で済む最小限の変更であり、既存の同期パスには一切影響を与えません。
まとめ
limit(0) という特殊ケースで async_count が Promise を返さないという型契約の破れが、1行の修正で解消されました。Promise::Complete を使って確定済みの結果をラップするパターンは、非同期インターフェースの一貫性を保つ上で参考になる設計判断です。