Make serialized attribute decorators Ractor-shareable
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:
- It captured
typeandcomparable. Those are keyword arguments with defaults, andRactor.make_shareablerejects them as outer variables that "may be reassigned", even when nothing reassigns them. - 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_shareablewhenserializeis called. The serializer is frozen only when anunshareable_proc_actionis 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 withRactor::IsolationError: cannot make a shareable Proc because the outer variable 'type' may be reassigned.Once the keyword captures are fixed, it fails oncolumn_serializerwith 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) andserialized_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.