【Ruby on Rails】find_eachの内部実装を追う

# はじめに

こちらはアドベントカレンダー18日目の記事です。

https://qiita.com/advent-calendar/2024/ctoa-24


本記事では、find_eachの内部実装を調査しながら、その処理内容や設計上の意図について考察します。


# 実装を追う
本記事ではRailsドキュメントに記載がある以下のfind_eachの使用例において、実行されるコードを追っていこうと思います。
```ruby
Person.find_each do |person|
  person.do_awesome_stuff
end
```

## find_eachメソッド
block_given?メソッドはカレントコンテキストにブロックが渡されていればtrueが返されるのでfind_in_batchesが実行されます。find_in_batchesメソッドへブロックを渡しており、ブロック内部ではfind_each実行時に渡したブロックをeachメソッドに渡しています。つまり、recordsはPersonモデルのオブジェクトであることがわかります。
```ruby
def find_each(start: nil, finish: nil, batch_size: 1000, error_on_ignore: nil, order: :asc, &block)
  if block_given?
    find_in_batches(start: start, finish: finish, batch_size: batch_size, error_on_ignore: error_on_ignore, order: order) do |records|
      records.each(&block)
    end
  else
    ...
  end
end
```
https://github.com/rails/rails/blob/984c3ef2775781d47efa9f541ce570daa2434a80/activerecord/lib/active_record/relation/batches.rb#L68-L79

### 考察

- find_in_batchesはバッチ処理なので、recordsはバッチ単位ごとのPersonモデルのオブジェクトの配列であるはずです。
- find_in_batchesの内部ではrecordsを渡すyieldを実行しており、第一引数としてPersonモデルのオブジェクトがどこかで渡されるはず。
- startとfinishでは処理の位置を指定しているがプライマリキーを基準にしていそうです。

## find_in_batchesメソッド
引数をそのままリレーして、in_batchesを実行しています。ブロックの内部ではfind_eachで考察した通り、yieldが実行されており、第一引数としてブロック引数のArrayオブジェクトを返しています。
```ruby
def find_in_batches(start: nil, finish: nil, batch_size: 1000, error_on_ignore: nil, order: :asc)
  ...

  in_batches(of: batch_size, start: start, finish: finish, load: true, error_on_ignore: error_on_ignore, order: order) do |batch|
    yield batch.to_a
  end
end

```

https://github.com/rails/rails/blob/984c3ef2775781d47efa9f541ce570daa2434a80/activerecord/lib/active_record/relation/batches.rb#L128-L140


## in_batchesメソッド
find_eachの核となるメソッドです。まとまりごとに一つずつ見ていきましょう。
```ruby
def in_batches(of: 1000, start: nil, finish: nil, load: false, error_on_ignore: nil, order: :asc)
  relation = self

  ...

  batch_limit = of
  if limit_value
    remaining   = limit_value
    batch_limit = remaining if remaining < batch_limit
  end

  relation = relation.reorder(batch_order(order)).limit(batch_limit)
  relation = apply_limits(relation, start, finish, order)
  relation.skip_query_cache! # Retaining the results in the query cache would undermine the point of batching
  batch_relation = relation

  loop do
    if load
      records = batch_relation.records
      ids = records.map(&:id)
      yielded_relation = where(primary_key => ids)
      yielded_relation.load_records(records)
    else
      ...
    end

    break if ids.empty?

    primary_key_offset = ids.last
    raise ArgumentError.new("Primary key not included in the custom select clause") unless primary_key_offset

    yield yielded_relation

    break if ids.length < batch_limit

    ...

    batch_relation = relation.where(
      predicate_builder[primary_key, primary_key_offset, order == :desc ? :lt : :gt]
    )
  end
end
```
https://github.com/rails/rails/blob/984c3ef2775781d47efa9f541ce570daa2434a80/activerecord/lib/active_record/relation/batches.rb#L204

### relation
relationにはfind_eachを呼び出したレシーバ（今回の例ではPerson）がselfとして代入されます。

```ruby
  relation = self
```

### batch_limitとremaining
batch_limitにはfind_eachからリレーされてきた1000が代入されます。limit_valueについては[limitの実装](https://github.com/rails/rails/blob/c67d91b04d16072f902e86bd969946120449b1c5/activerecord/lib/active_record/relation/query_methods.rb#L1215)を読むとわかりますが、limitメソッドの引数が代入されています。limitが指定されている場合、batch_limitはその上限に調整されます。

今回のケースではlimitをチェーンしたActiveRecordのオブジェクトではないので条件文は実行されません。
```ruby
  batch_limit = of
  if limit_value
    remaining   = limit_value
    batch_limit = remaining if remaining < batch_limit
  end
```

### batch_relation
先ほどrelationを定義しましたが、このままではbatch単位のオブジェクトになっていないのでrelationをbatch単位にしています。[apply_limitsの実装](https://github.com/rails/rails/blob/984c3ef2775781d47efa9f541ce570daa2434a80/activerecord/lib/active_record/relation/batches.rb#L268)の実装を読むと、limitをかけているだけではなく、プライマリキーについてsortも走っていることがわかります。sortを走らせているのはwhere条件でプライマリキーの範囲をstartとfinishで指定し、limitの処理で適切な数のレコードを取得するために必要で、これは確かにapply_limitsの責務です。

クエリキャッシュを無効にしているのはキャッシュをしてしまうと、batch単位ごとに処理することでメモリ効率化を図るというbatch処理の意義が損なわれてしまうためです。

```ruby
  relation = relation.reorder(batch_order(order)).limit(batch_limit)
  relation = apply_limits(relation, start, finish, order)
  relation.skip_query_cache! # Retaining the results in the query cache would undermine the point of batching
  batch_relation = relation
```

### loop内部
ここでbatch処理全体を実行しています。注目するべきポイントは3つです。

- 1つ目はyieldです。yielded_relation(今回のケースではPerson)がin_batchesへ渡しているブロック引数に代入されます。
- 2つ目はbreakの条件です。ループごとにbatch単位のidsを取得しているのですが、idsが空(処理するべきbatch対象がない)場合とidsがbatchサイズより小さい場合です。
- 3つ目はprimary_key_offsetです。これでbatch処理のoffsetを指定しています。
```ruby
  loop do
    if load
      records = batch_relation.records
      ids = records.map(&:id)
      yielded_relation = where(primary_key => ids)
      yielded_relation.load_records(records)
    else
      ...
    end

    break if ids.empty?

    primary_key_offset = ids.last
    raise ArgumentError.new("Primary key not included in the custom select clause") unless primary_key_offset

    yield yielded_relation

    break if ids.length < batch_limit

    ...

    batch_relation = relation.where(
      predicate_builder[primary_key, primary_key_offset, order == :desc ? :lt : :gt]
    )
  end
```

# 最後に
メモリ節約によく利用されるfind_eachについて見てきました。内部実装を読むことで、Rubyの知らないメソッドに出会えたり、Railsにおけるインターフェースの思想に触れられて設計力が向上するのでおすすめです。

# 参考
https://railsdoc.com/page/find_each