Railsを7.0から7.1にアップグレードした後、Oracle→PostgreSQLへのバッチ連携でレコードの取りこぼしが発生する、という現象に遭遇したので、原因を追ってみました。
環境は以下の通りです。
- Rails 7.1
- DB: PostgreSQL / Oracle
- oracle-enhanced
- ActiveRecordのSchema Cacheを使用
バッチ処理の内容
対象のバッチは以下のように、OracleからPostgreSQLに Hoge レコードを1000件ずつ連携する処理です。(簡略化しています)
Backend::Hoge.active.find_each do |backend_record|
  frontend_record = ::Hoge.new(backend_record.attributes_before_type_cast)
  frontend_record.save
end
件数差異の発生
size で件数を取得した時と、 find_each を実行した時とで、以下のように件数に差が出ていました。
- Backend::Hoge.active.sizeを実行した時 → 48692件
- find_each(batch_size: 1000)を実行した時 → 46002件
find_eachの方が約2700件レコード件数が少なくなってしまっています。
SQLの比較
生成されるSQLが変わってしまったのではないかと思い、 アップグレード前後で find_each が出力するSQLを比較してみました。
アップグレード前:
SELECT
  "HOGE".*
FROM
  "HOGE"
WHERE
  "HOGE"."DELETE_FLG" = :a1
  AND (
    "HOGE"."FUGA_CODE" > :a2
    OR (
      "HOGE"."FUGA_CODE" = :a3
      AND "HOGE"."PIYO_CODE" > :a4
    )
  )
ORDER BY
  "HOGE"."FUGA_CODE" ASC,
  "HOGE"."PIYO_CODE" ASC
FETCH FIRST :a2 ROWS ONLY
アップグレード後:
SELECT
  *
FROM
  (
    SELECT
      "HOGE".*
    FROM
      "HOGE"
    WHERE
      "HOGE"."DELETE_FLG" = :a1
      AND (
        "HOGE"."FUGA_CODE" > :a2
        OR (
          "HOGE"."FUGA_CODE" = :a3
          AND "HOGE"."PIYO_CODE" > :a4
        )
      )
    ORDER BY
      "HOGE"."FUGA_CODE" ASC,
      "HOGE"."PIYO_CODE" ASC
  )
WHERE
  ROWNUM <= :a5
差分としては、 FETCH FIRST 〜 ROWS ONLY ではなく、サブクエリ と ROWNUM を使うように変わっているのがわかります。
ROWNUMを使うようになったのが原因?
ROWNUM とは、Oracleで使える、結果の行番号を返してくれる擬似列です。行番号をWHERE 句で絞ることで、LIMIT のように行数を制限できます。
ROWNUMについての詳細は、以前書いた記事をご覧ください:
FETCH FIRST ではなく ROWNUM が使われなくなった原因としては、oracle-enhanced #2395 のIssueにもあるように、supports_fetch_first_n_rows_and_offset? というメソッドが常に false を返すように修正されたからのようです。
ただし、試しにパッチを当てて FETCH FIRST を使わせても解消しなかったため、取りこぼしの原因は ROWNUM によるものではありませんでした。
バインドパラメータの内容を確認
Railsコンソールのログを見ると、バインド値として以下のように値と型情報が渡されていました。
[["delete_flg", #<ActiveRecord::Type::OracleEnhanced::CharacterString::Data:0x00007fd91e9eb480 @value="0", @limit=1>],
 ["fuga_code", #<ActiveRecord::Type::OracleEnhanced::CharacterString::Data:0x00007fd91d98ed58 @value="0004", @limit=12>],
 ["fuga_code", #<ActiveRecord::Type::OracleEnhanced::CharacterString::Data:0x00007fd91d98ed30 @value="0004", @limit=12>],
 ["piyo_code", #<ActiveRecord::Type::OracleEnhanced::CharacterString::Data:0x00007fd91d98ed08 @value="456", @limit=9>],
 ["LIMIT", 1000]]
出力をよく見ると、 fuga_code や piyo_code には limit: 〜 というカラム長の情報が付いていますが、これが実際のスキーマと異なっていることが確認できました。
なぜ実際のスキーマとは異なるカラム長が指定されていたかというと、このプロジェクトではActiveRecordのSchema Cacheという機能を利用しており、Schema Cacheの方で誤ったカラム長を指定してしまっているのが原因のようでした。
以下のように正しいlimitを指定して実行すると、取りこぼしが解消されることを確認できました。
sql = <<-SQL
 (SQL文省略)
SQL
connection = Backend::Hoge.connection
binds = [
  ActiveRecord::Relation::QueryAttribute.new("a1", "0", (ActiveRecord::Type::OracleEnhanced::CharacterString.new(limit: 1))),
  ActiveRecord::Relation::QueryAttribute.new("a2", "0004", ActiveRecord::Type::OracleEnhanced::CharacterString.new(limit: 4)),
  ActiveRecord::Relation::QueryAttribute.new("a3", "0004", ActiveRecord::Type::OracleEnhanced::CharacterString.new(limit: 4)),
  ActiveRecord::Relation::QueryAttribute.new("a4", "456", ActiveRecord::Type::OracleEnhanced::CharacterString.new(limit: 3)),
  ActiveRecord::Relation::QueryAttribute.new("a5", 1000, ActiveRecord::Type::Integer.new)
]
connection.exec_query(sql, "SQL", binds)
なぜカラム長が異なると取りこぼしが発生するのか
Rails のアップグレード前後で Schema Cache は変えておらず、アップグレード前は取りこぼしも発生していませんでした。
そのため、 oracle-enhanced もしくは ActiveRecord に何らかの修正が加わったのではと考えています。
ただし、詳しい原因はまだ特定できていないので、時間があるときに引き続き調査しようと思います。何かわかれば追記するかもしれません。
(最新のRails 8対応バージョンのoracle-enhancedでは未検証なので、もしかしたら既に問題が解消されている可能性があるかもしれません)
まとめ
OracleでかつSchema Cacheを利用しているという環境はレアケースかもしれませんが、
Schema Cache と実際のスキーマとの差で思わぬ挙動が出ることもあるかも、ということで頭の片隅に置いておくと良さそうです。
 
       
                      