Rails 8.0からのMarshalデシリアライズ時のInteger型の回帰修正
Rails 8.0でMarshalシリアライズされた ActiveModel::Type::Integer オブジェクトを8.1で読み込むと NoMethodError が発生する回帰が修正されました。この問題は主にデフォルトのMarshalling形式(6.1)を使用しているアプリケーションに影響し、本番環境で即座にエラーを引き起こす可能性がありました。
背景
#53470 で導入されたパフォーマンス最適化により、Type::Integer#initialize 内で @range が @max と @min に置き換えられました。しかし、この変更によりMarshal互換性に関する副作用が見落とされていました。
Marshal.load はインスタンス変数を直接復元し、initialize メソッドを呼び出しません。そのため、Rails 8.0でシリアライズされたオブジェクト(@range を持つが @max/@min を持たない)を8.1で読み込むと、@max = nil および @min = nil の状態になります。その結果、out_of_range? メソッドが nil <= value を呼び出し、NoMethodError が発生していました。
この問題はスキーマキャッシュや属性ビルダーなど、Type オブジェクトがキャッシュされる様々な文脈で発生する可能性があります。フォーマット7.1へのアップグレードは AR::Base オブジェクトには有効ですが、Type クラス自体の堅牢性が求められます。
技術的な変更
activemodel/lib/active_model/type/integer.rb の out_of_range? メソッドに遅延初期化ロジックが追加されました。
変更後:
private
def out_of_range?(value)
if @max.nil?
@max = max_value
@min = min_value
end
value && (@max <= value || @min > value)
end
@max が nil の場合にのみ、max_value および min_value から値を再計算します。これらのメソッドは親クラス Value が正しく復元する limit から派生するため、Marshal経由でも正確な値を得られます。
テストでは2つのシナリオが追加されています。1つ目は Rails 8.0 の状態をシミュレートしたケースです:
test "deserialising type without @max/@min" do
type = Integer.new
# Rails 8.0からのMarshal読み込みをシミュレート
type.remove_instance_variable(:@max)
type.remove_instance_variable(:@min)
type.instance_variable_set(
:@range,
type.send(:min_value)...type.send(:max_value)
)
assert_equal 42, type.serialize(42)
assert_equal(-1, type.serialize(-1))
assert_nil type.serialize(nil)
assert_raises(ActiveModel::RangeError) do
type.serialize(2**31)
end
end
2つ目は実際のMarshalラウンドトリップを検証するケースです:
test "Marshal round-trip preserves behaviour" do
type = Integer.new
restored = Marshal.load(Marshal.dump(type))
assert_equal 42, restored.serialize(42)
assert_equal(-1, restored.serialize(-1))
assert_nil restored.serialize(nil)
assert_raises(ActiveModel::RangeError) do
restored.serialize(2**31)
end
end
設計判断
遅延初期化による後方互換性の確保が採用されました。
通常のコードパスではガードは一度も実行されず、パフォーマンスへの影響はありません。劣化パス(Rails 8.0からの移行時)でのみ、最初の out_of_range? 呼び出し時に一度だけ初期化が実行されます。この設計により、キャッシュ全体をフラッシュする回避策を必要とせず、マイナーバージョンアップグレードの透過性が保たれます。
PRの説明では「キャッシュ全体のフラッシュという代替手段もあるが、マイナーバージョンアップグレードでそれを要求すべきではない」と明記されており、運用上の継続性を重視した判断が反映されています。
まとめ
本PRは、パフォーマンス最適化が引き起こしたMarshal互換性の回帰を、最小限のコード変更で修正しています。遅延初期化により通常のパフォーマンスを維持しながら、Rails 8.0から8.1への移行時の本番障害リスクを排除しました。Type オブジェクトがキャッシュされる様々な文脈での堅牢性を確保する、実践的な設計といえます。