`in_batches`で`use_ranges`とlimitの組み合わせ時に負のLIMITが発生する不具合を修正
Active Recordのin_batchesメソッドでuse_ranges: trueとlimitを組み合わせた際、limitがバッチサイズの倍数でない場合に「LIMIT must not be negative」エラーが発生する不具合が修正されました。この問題は、バッチ処理の残件数管理に起因していました。
背景
in_batchesは大量のレコードを一定のサイズごとに分割して処理するメソッドです。use_rangesオプションは、効率的なバッチ処理のために範囲ベースのクエリを使用する機能です。しかし、このオプションとlimitを組み合わせた際、特定の条件下でデータベースエラーが発生していました。
#56819で報告された問題では、以下のようなコードでエラーが発生します:
# データベースに11件のPostレコードが存在する場合
Post.limit(5).in_batches(of: 3, use_ranges: true) { |batch| batch.count }
# => PG::InvalidRowCountInLimitClause: ERROR: LIMIT must not be negative
このコードは、5件のレコードを3件ずつのバッチで処理しようとしています(1回目に3件、2回目に2件)。しかし、実際には2回目の処理前に負のLIMIT値が設定され、PostgreSQLなどのデータベースがエラーを返していました。
技術的な変更
activerecord/lib/active_record/relation/batches.rbのbatch_on_unloaded_relationメソッド内で、values_sizeの計算ロジックが修正されました。
変更前:
values_size = batch_limit
values_last = batch_relation.offset(batch_limit - 1).pick(*cursor)
変更後:
values_size = remaining ? [batch_limit, remaining].min : batch_limit
values_last = batch_relation.offset(values_size - 1).pick(*cursor)
変更前のコードでは、values_sizeは常にbatch_limit(元のバッチサイズ)に設定されていました。しかし、残件数がbatch_limitより少ない場合でも、この値が更新されずに残っていました。そのため、次のイテレーションでremaining -= values_sizeを実行すると、実際に処理された件数より大きな値が減算され、remainingが負になっていました。
修正後は、values_sizeをremainingとbatch_limitの小さい方に設定することで、常に実際の処理件数を反映するようになりました。これにより、remainingの計算が正確になり、負のLIMIT値が設定されることがなくなりました。
問題の発生メカニズム
不具合の発生メカニズムを具体例で説明します。Post.limit(5).in_batches(of: 3, use_ranges: true)の場合:
1回目のイテレーション:
- remaining = 5
- batch_limit = 3
- relation.limit(5)でクエリ実行
- values_size = 3(修正前)
- remaining -= 3 → remaining = 2
2回目のイテレーション:
- remaining = 2(batch_limit = 3より小さい)
- relation = relation.limit(2)に更新される
- しかしbatch_limitは3のまま
- values_size = 3(修正前、実際には2件しか取得されない)
- remaining -= 3 → remaining = -1
3回目のイテレーション:
- remaining = -1
- relation.limit(-1)が実行され、データベースエラー発生
修正後は、2回目のイテレーションでvalues_size = min(3, 2) = 2となるため、remaining -= 2でremaining = 0となり、3回目のイテレーションは発生しません。
設計判断
values_sizeの計算タイミングでの修正という最小限の変更が採用されました。
この問題は、use_rangesコードパスにおいてvalues_sizeが実際の取得件数ではなく想定バッチサイズに固定されていたことが原因でした。修正では、remainingが存在する場合(limitが設定されている場合)に限り、batch_limitとremainingの小さい方を使用するよう変更されています。
offsetの計算でも同じvalues_sizeが使用されるため、この修正は二重の効果を持ちます。正確なvalues_sizeにより、次のバッチの開始位置を示すvalues_lastの取得も正しく行われるようになりました。
limitが設定されていない場合(remainingがnilの場合)は従来通りbatch_limitを使用する条件分岐により、既存の動作への影響を最小限に抑えています。
まとめ
本PRは、in_batchesのuse_rangesオプション使用時に、limitとバッチサイズの組み合わせによって発生していた負のLIMIT値の問題を修正しました。values_sizeの計算ロジックに1行の変更を加えることで、残件数の管理を正確にし、limitが設定されたバッチ処理を安全に実行できるようになりました。use_rangesオプションを使用するアプリケーションにとって重要な修正です。