`insert_all`でシリアライズ済み文字列が二重エンコードされるバグを修正
#56855 が導入した insert_all のパフォーマンス最適化に、シリアライズ済みカラムへ文字列を挿入する際に二重エンコードが発生するバグが含まれていました。本PRはその修正であり、1行の条件式変更で insert_all と create の挙動の一貫性を回復します。
背景
#56855 は、シリアライズカラムを持つテーブルへの insert_all を高速化するため、InsertAll::Builder#values_list にホットパスを追加しました。Mutable#cast の実装は deserialize(serialize(value)) であり、シリアライズカラムでは coder.load(coder.dump(value)) という往復処理になります。Hash や Array はすでに正規化済みの Ruby オブジェクトであるため、この往復はコストだけかかって結果が変わらない冗長な処理でした。そこで type.serialized? の場合は cast をスキップする最適化が導入されました。
しかし、この最適化は値が文字列である場合に誤動作します。insert_all 経由で文字列を渡すと、cast をスキップして SerializeCastValue.serialize に直接渡されます。coder.dump("already_encoded") が呼ばれ、すでにエンコード済みの文字列をさらに一度エンコードする二重エンコードが発生します。一方 create では cast が呼ばれ、coder.load(coder.dump(value)) の往復処理で文字列を正規化してから serialize するため、この問題は起きません。
create と insert_all で同じ文字列を渡した場合にデータベースに保存される値が異なるという、データ破損を招くバグでした。
技術的な変更
activerecord/lib/active_record/insert_all.rb の1行変更で修正されています。cast をスキップする条件に && !value.is_a?(String) を追加し、値が文字列の場合はシリアライズカラムであっても cast を通すようにしました。
変更前:
value = type.cast(value) unless type.serialized?
変更後:
value = type.cast(value) unless type.serialized? && !value.is_a?(String)
is_a?(String) の評価は O(1) であり、文字列以外の値(Hash、Array、カスタムオブジェクト)には引き続きホットパスが適用されます。文字列が渡された場合のみ cast を経由するフルパスに落ちるため、#56855 の性能改善は一般的なケースで維持されます。
テストでは意図的に非冪等な ReverseCoder を実装しています。dump("foo") が "encoded:foo" を返すため、二重エンコードが起きると "encoded:encoded:foo" になり、アサーションで即座に検知できます。4つのテストケースで、文字列値の insert_all! と create! の結果が一致すること、および二重エンコードが発生しないことを確認しています。
設計判断
文字列のみを特別扱いする最小限の修正が採用されています。PR の説明では、文字列は「すでに Ruby ドメインの正規オブジェクトとして渡されている Hash/Array」とは異なり、生の Ruby 文字列なのかデータベース用にエンコード済みの表現なのかが曖昧であると説明されています。この曖昧性を解消するには create と同じく cast を通す必要があり、その判定コストは is_a? の O(1) で十分安価です。
シリアライズカラム上の cast をまとめてオーバーライドする案も #56855 の段階で検討されましたが、属性代入など他の操作にも影響が及ぶとして退けられた経緯があります。今回の修正はその判断を維持しつつ、values_list のホットパス内だけで型による分岐を行う局所的なアプローチです。
まとめ
本PRは、insert_all の最適化が文字列という特殊なケースで引き起こしたデータ破損バグを1行の条件式追加で修正します。「文字列か否か」という型チェックを挟むだけで Hash/Array のホットパスを維持しつつ create との動作一貫性を回復しており、最小コストで安全性と性能を両立した設計といえます。