Make serialized attribute decorators Ractor-shareable

MH
8h ago
+34-9

Motivation / Background

This Pull Request has been created because the decorator that serialize registers can never be made Ractor-shareable. Since 51a2a815a2, loading a model's schema context makes its pending attribute modifications shareable through try_make_shareable. With unshareable_proc_action = :raise, any model that calls serialize or store raises there. One such model is Active Storage's own ActiveStorage::Blob (store :metadata, coder: ActiveRecord::Coders::JSON), so that model stops Rails.application.ractorize!:

ractorize: Rails.application.ractorize! failed: Ractor::IsolationError: cannot make a shareable Proc because it can refer unshareable object #<ActiveRecord::Store::IndifferentCoder:0x000000012d7556d8 @coder=#<ActiveRecord::Coders::JSON:0x000000012d755980 @decode_options=nil, @encoder=#<ActiveSupport::JSON::Encoding::JSONGemCoderEncoder:0x000000012d755890 @escape=false, @options={}>>> from variable 'column_serializer'
      at activesupport/lib/active_support/ractors.rb:133:in 'Ractor.make_shareable'

It is raised from ActiveModel::AttributeRegistration::ClassMethods#make_pending_attribute_modifications_shareable when the schema context loads.

The block failed for two reasons:

  1. It captured type and comparable. Those are keyword arguments with defaults, and Ractor.make_shareable rejects them as outer variables that "may be reassigned", even when nothing reassigns them.
  2. It captured column_serializer, a coder instance that is not frozen.

Detail

This Pull Request changes serialize in two places:

  • It builds the decorator in a private method, serialized_type_decorator, that takes positional arguments, so the proc no longer captures keyword arguments.
  • It passes the column serializer through ActiveSupport::Ractors.try_make_shareable when serialize is called. The serializer is frozen only when an unshareable_proc_action is set, so apps that don't opt in keep their coders unfrozen.

store goes through serialize, so both are covered.

Additional information

Rails' own coders (Coders::JSON, Coders::YAMLColumn, Coders::ColumnSerializer, Store::IndifferentCoder) keep no lazy state on the instance, so freezing them is safe. With an unshareable_proc_action set, a custom coder is frozen too, so one that memoizes on itself would need to stop. A custom coder that can't be made shareable now raises under :raise when serialize is called, rather than later when the schema loads.

The test decodes the JSON store column inside a Ractor. It doesn't decode the YAML serialize column there. YAMLColumn::SafeCoder#load reads ActiveRecord.yaml_column_permitted_classes, which is still unshareable after ractorize!. That is a separate change.

I checked the fix against an app as well as the unit test. On Ruby 4.0.6 and this branch, a Phlex app (with Active Storage loaded) that failed in ractorize! with the error above now boots in production. Rails.application.ractorize! returns, and a request served inside a non-main Ractor returns 200.

Checklist

Before submitting the PR make sure the following are checked:

  • This Pull Request is related to one change. Unrelated changes should be opened in separate PRs.
  • Commit message has a detailed description of what changed and why. If this PR fixes a related issue include it in the commit message. Ex: [Fix #issue-number]
  • Tests are added or updated if you fix a bug or add a feature.
  • CHANGELOG files are updated for the changed libraries if there is a behavior change or additional feature. Minor bug fixes and documentation changes should not be included.

Conformance case

activerecord/test/cases/core_test.rb:392: "serialized attributes are shareable and applied on a non-main Ractor"

cd activerecord && ARCONN=sqlite3 bin/test test/cases/core_test.rb -n "/serialized_attributes_are_shareable/"
  • Without the fix (50c54eed06), on Ruby 4.0.6, it fails with Ractor::IsolationError: cannot make a shareable Proc because the outer variable 'type' may be reassigned. Once the keyword captures are fixed, it fails on column_serializer with the error above.
  • With this PR, it passes on Ruby 4.0.6. These also pass on Ruby 4.0.6 (sqlite3): core_test.rb (31 runs), store_test.rb (54 runs) and serialized_attribute_test.rb (117 runs, 1 skip).

The test defines a model with both serialize and store under unshareable_proc_action: :raise, the way an app boots for ractorize!. It asserts that the pending modifications are shareable, so it fails again if either decorator captures something unshareable.