Skip to content

Boot the Ractor test runner like a deploy - #22

Open
mhenrixon wants to merge 3 commits into
Shopify:mainfrom
mhenrixon:runner-production-boot
Open

mhenrixon wants to merge 3 commits into
Shopify:mainfrom
mhenrixon:runner-production-boot

Conversation

@mhenrixon

@mhenrixon mhenrixon commented Oct 17, 2026 •

Copy link
Copy Markdown

I copied bin/ractor-test into an app generated from ractor_safe_rails_template.rb and found two places where the runner boots differently from a server running that app. Both hide failures.

Changes

  • Load i18n/ractorize after ractorize!, as the template's config.ru does. The runner only called ractorize!, so I18n.config stayed unshareable and both scaffold form pages (/posts/new, /posts/:id/edit) raised @backend from I18n::Config in the request Ractor. The require is wrapped in rescue LoadError because the committed app is on released i18n 1.15.2, which has no such file. LoadError isn't a StandardError, so without the rescue it gets past phase 1's rescue => error and crashes bin/ractor-test.
  • Prepare and seed the database the way a deploy does, in a separate process. The runner seeded through Post on the main Ractor before ractorize!, and that defined Post's attribute methods. A production boot defines them only from a schema cache dump: with check_schema_cache_dump_version = false, which the template sets, the active_record.define_attribute_methods initializer defines them for every table found in db/schema_cache.yml. Without a dump, the first request to use a model has to define them inside its Ractor, and it raises can not access non-shareable objects in constant ActiveRecord::AttributeMethods::GeneratedAttributeMethods::LOCK. The runner now runs bin/rails db:prepare db:schema:cache:dump db:seed before booting (this replaces the load db/schema.rb fallback). db/seeds.rb creates the two posts the requests use, so seeding goes through Post in that process and the runner reads the ids back with SQL. .gitignore ignores the dumps it writes. The committed app writes three: primary, cache and queue.

Verification

Setup: Ruby 4.0.7 (the repo pins 4.0.2) and rails/rails main 7ca3884f17. I generated the app with rails new -m ractor_safe_rails_template.rb --skip-solid, so it is on load_defaults 8.2, SQLite. Then I ran bin/rails g scaffold Post body:text and assets:precompile, and copied in bin/ractor-test, script/ractor_test.rb and script/requests.yml.erb.

With 8.2 defaults every HTML page fails on @configuration from Herb before reaching either change. To see past that, the erubi column sets config.action_view.erb_implementation = :erubi locally. That setting is not part of this PR; Herb is covered in #23.

runner herb (8.2 default) erubi (local override)
main 4/16 9/16: both form pages fail on @backend from I18n::Config
this branch 4/16 11/16

This branch's seeding without the db:schema:cache:dump step gives 1/16 (erubi or herb). With erubi, 13 requests fail on GeneratedAttributeMethods::LOCK, including the POST/PATCH/DELETE that pass on main. That is what the old model-based seeding was hiding. A deploy built from the template runs db:prepare and nothing writes the dump, so it would fail the same way.

The 5 failures left at 11/16 are Ruby 4.0 class-variable reads from a non-main Ractor, outside the scope of this PR: GET /up (@@silencer from ActiveSupport::BroadcastLogger, through silence_healthcheck_path) and the 4 JSON routes (@@key_formatter from Jbuilder).

The committed app (bundle exec bin/ractor-test): 1/16 on main and 1/16 on this branch, with the same errors (Rack::Mime::MIME_TYPES on GETs, a class ivar set on writes). The branch doesn't change that app's results. It now runs db:prepare and writes the three dumps.

RuboCop adds no offenses to script/ractor_test.rb (the 5 reported by rubocop-rails-omakase are already on main).

Conformance case

This PR changes the runner itself (script/ractor_test.rb), so it adds no unit test. The case is the runner:

bundle exec bin/ractor-test

The numbers under Verification come from an app generated from the template, not from this repo's committed app. The repeatable check is running bin/ractor-test in CI against an app regenerated from the template, so a runner that boots differently from a deploy shows up as a changed pass count (#23).

The template's config.ru requires i18n/ractorize after ractorize!, but the
runner only called ractorize!, so I18n's config stayed unshareable and every
page that translates (the scaffold's forms) raised on @backend from
I18n::Config in the request Ractor. Released i18n has no such file, so a
missing one is skipped.
Seeding through Post on the main Ractor defined its attribute methods before
ractorize!, which hid that a production boot only defines them from a schema
cache dump (with check_schema_cache_dump_version = false, as the template
sets). Without one, a model's first use in a request Ractor raises on
ActiveRecord::AttributeMethods::GeneratedAttributeMethods::LOCK.

Run db:prepare and db:schema:cache:dump before booting, and seed with SQL so
no model is touched on the main Ractor.
Comment thread script/ractor_test.rb
"Seed", "id"
)
end
end

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This make sense. But maybe we should just shell out to db:seed for this too

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I think that makes sense! Will address tomorrow morning.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 4e8ed0b. db/seeds.rb now creates the two posts, and the runner adds db:seed to the bin/rails db:prepare db:schema:cache:dump call. Seeding goes through Post again, just in that separate process, so the runner itself still never touches the model before ractorize!. It reads the two ids back with a SELECT.

On Ruby 4.0.7 the committed app gives the same 1/16 as before, with the same failure for every request, both from a fresh database and with an existing one. The ids just come out higher, because SQLite keeps counting after Post.delete_all.

db/seeds.rb now creates the two posts the requests use, and the runner
runs db:seed in the same bin/rails process as db:prepare and
db:schema:cache:dump. Seeding can use Post again, and the runner's own
process still never touches a model before the application is
ractorized. The runner reads the two ids back with SQL.
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.

2 participants