Skip to content

Make class-level defaults readable from non-main Ractors - #626

Open
mhenrixon wants to merge 1 commit into
rails:mainfrom
mhenrixon:ractor-shareable-defaults
Open

mhenrixon wants to merge 1 commit into
rails:mainfrom
mhenrixon:ractor-shareable-defaults

Conversation

@mhenrixon

@mhenrixon mhenrixon commented Oct 17, 2026 •

Copy link
Copy Markdown

Rails main has an experimental Rails.application.ractorize! that serves requests from non-main Ractors on Ruby 4.0. Every jbuilder view fails there: Jbuilder#initialize reads the class-level defaults from class variables, and Ruby 4.0 refuses every class variable read from a non-main Ractor.

require "jbuilder"
Ractor.new { Jbuilder.new { |json| json.name "David" }.attributes! }.value
# main, Ruby 4.0.7:
# lib/jbuilder.rb:18:in 'Jbuilder#initialize': can not access class variables from non-main Ractors (@@key_formatter from Jbuilder) (Ractor::IsolationError)
# this branch:
# => {"name" => "David"}

Ruby master (4.1) already lets non-main Ractors read a class variable that holds a shareable value (see rails/rails#58974). That covers the two booleans but not @@key_formatter: a KeyFormatter holds a Mutex and a mutable cache, so a Ractor can't read it on any Ruby.

Changes

  • The defaults are now class instance variables of Jbuilder. They are read and written through ::Jbuilder, so JbuilderTemplate and other subclasses still share them, as they did with the class variables. Jbuilder.key_format, ignore_nil and deep_format_keys behave the same.
  • KeyFormatter can be frozen. A frozen formatter still returns its cached keys and formats the others without storing them. freeze drops the Mutex, because a Mutex can't be shared between Ractors.
  • The Railtie makes the default formatter and JbuilderTemplate.template_lookup_options shareable on Rails' before_ractorize load hook. Rails' own railties use the same hook. Older Rails never runs it, so nothing changes without ractorize!.

The process-wide cache from #597/#607 still works as before on the main Ractor. I measured no difference: 200k builds with 5 camelized keys took 0.216s/0.217s on main and 0.199s/0.212s on this branch (Ruby 4.0.7, best of 5). Under ractorize! the default formatter is frozen, so it formats keys on each call, as it did before #597. A formatter built from a lambda makes the hook raise at ractorize!, unless the lambda was made shareable (Ractor.shareable_lambda), so the failure shows up at boot and not on a request.

Tests

  • Defaults set on Jbuilder apply to subclasses, and defaults set on a subclass apply to Jbuilder. A template test checks the same for JbuilderTemplate.
  • A frozen KeyFormatter still formats keys, both cached and new.
  • The before_ractorize hook makes the default formatter and the lookup options shareable.
  • A Jbuilder built in a non-main Ractor picks up the default key format and ignore_nil. This test needs Ractor#value, so it is skipped below Ruby 4.0.
  • The existing tests that called class_variable_set/class_variable_get now use the setters and the new readers.

CI=1 bundle exec rake test: Ruby 4.0.7 + Rails 8.1 gives 143 runs, 0 failures, 0 skips. Ruby 3.1.6 + 7.0, 3.2.8 + 7.1 and 3.4.11 + 8.0 give 0 failures, with the one Ruby 4.0-only skip. On rails_head (allowed to fail), 6 collection-partial tests in jbuilder_template_test.rb fail on main too, against today's Rails main. This branch fails the same 6 and nothing else (checked over 5 seeds).

I also checked it in a Rails main app under ractorize!, with GET /posts.json running in a non-main Ractor. Before: Ractor::IsolationError … (@@key_formatter from Jbuilder) @ lib/jbuilder.rb:18. After: 200 both without a default format and with Jbuilder.key_format camelize: :lower plus Jbuilder.ignore_nil in an initializer.

Conformance case

test/jbuilder_test.rb:840: "defaults can be read from a non-main Ractor once shareable"

BUNDLE_GEMFILE=gemfiles/rails_8_1.gemfile bundle exec ruby -Itest test/jbuilder_test.rb -n /non-main_Ractor_once_shareable/
  • Without the fix (f141f5e), Ruby 4.0.7: the test itself can't run there (it calls the new Jbuilder.default_key_formatter); its Ractor block alone fails with Ractor::IsolationError: can not access class variables from non-main Ractors (@@key_formatter from Jbuilder)
  • With this PR: passes on Ruby 4.0.7.

It builds a Jbuilder inside the Ractor, so it also fails if any default Jbuilder.new reads goes back into a class variable or isn't shareable. It skips below Ruby 4.0.

Ruby 4.0 refuses every class variable read from a non-main Ractor, so
`Jbuilder.new` raised `Ractor::IsolationError` on `@@key_formatter` in
every view of an app that serves requests from Ractors (Rails main's
experimental `Rails.application.ractorize!`).

Keep the defaults in class instance variables of Jbuilder instead. They
are read and written through `::Jbuilder`, so JbuilderTemplate and other
subclasses still share them, as they shared the class variables.

A non-main Ractor can read a class instance variable once its value is
shareable, so the Railtie makes the default KeyFormatter and
`JbuilderTemplate.template_lookup_options` shareable on Rails'
`before_ractorize` hook, which older Rails never runs. KeyFormatter can
now be frozen: it keeps serving cached keys and formats the others
without storing them.
@mhenrixon
mhenrixon force-pushed the ractor-shareable-defaults branch from 21a70c5 to 5b078de Compare October 17, 2026 11:20
@mhenrixon mhenrixon moved this to Waiting on maintainers in Ractor-safe Rails Oct 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant