Skip to content

feat(ractorize): the ractorize job, proven by a report-only lane on Rails main - #26

Merged
mhenrixon merged 2 commits into
mainfrom
issue-22-ractorize-job
Oct 19, 2026
Merged

mhenrixon merged 2 commits into
mainfrom
issue-22-ractorize-job

Conversation

@mhenrixon

@mhenrixon mhenrixon commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Closes #22. conformance-kit check --ractorize boots a gem's test app the way Rails 8.2 runs an app across Ractors. The reusable workflow gains a report-only Ractorize job. The kit proves the check against fixture apps in a report-only "Ractorize lane" on Rails main: option (a) from the issue discussion.

The job. For ractorize.app it:

  1. runs ractorize.setup in the app (with ractor.env, RAILS_ENV=production);
  2. in a new process, sets ActiveSupport::Ractors.unshareable_proc_action = :raise and boots in production;
  3. calls Rails.application.ractorize! and require "i18n/ractorize";
  4. serves each ractorize.requests path inside Ractor.new;
  5. audits ractorize.audit_namespaces (default audit.namespace) in the same process.

A failed request names the root cause and its first frame, also when the app's error page swallowed the exception (env["action_dispatch.exception"]). Below Ruby 4.0, or on a Rails without ractorize!, it skips with the reason and exits 0.

What the lane proves (Ruby 4.0.7, Rails main 0d022e0, Rack main a9833c8, Shopify/i18n#5 1873db3):

violating_boot (a route block appended from an initializer): exit 1
ractorize: boot failed: Ractor::IsolationError: Proc's self is not shareable: #<Proc:0x… spec/fixtures/ractorize/violating_boot/lib/violating_boot_engine.rb:8>

violating_render (a shallow-frozen class ivar read at render): exit 1
  GET /widget -> 500 (0 bytes): Ractor::IsolationError: can not get unshareable values from instance variables of classes/modules from non-main Ractors (@registry from ViolatingRenderEngine::Widget)
      at …/violating_render/app/controllers/widgets_controller.rb:5:in 'WidgetsController#show'
ractorize: audit ViolatingRenderEngine after ractorize!: 1 findings
  ViolatingRenderEngine::Widget.@registry (class ivar) Hash: [:names] is an Array that is not frozen: freeze it at boot (Ractor.make_shareable)

conforming: exit 0
ractorize: Rails.application.ractorize!: ok
  GET /widget -> 200 (10 bytes)
ractorize: audit ConformingEngine after ractorize!: 0 findings
ractorize passed

Changes

  • .conformance.yml: new keys ractorize.app, ractorize.requests, ractorize.audit_namespaces and ractorize.setup. They are validated like the rest: an unknown key exits 2, and so does --ractorize without the app or its config/environment.rb.
  • config/ractorize.gemfile: the repo's own Gemfile with Rails main, Rack main and Make I18n.fallbacks and INTERPOLATION_PATTERN readable from non-main Ractors Shopify/i18n#5 swapped in. It drops the repo's lines for those gems and Rails' component gems, since Bundler refuses a gem declared twice. Consumers copy it to Gemfile.ractorize; the lane uses the same file.
  • conformance.yml: a ractorize job, only when ractorize.app is set, continue-on-error until Rails 8.2. It changes no existing job or required check name.
  • main.yml: the report-only Ractorize lane, which runs rspec --tag ractorize on Ruby 4.0 with that gemfile.
  • README: "The ractorize job" covers what the app needs (production settings, schema cache, assets, main_only for Rails::Engine state), the bundle, and that the job is report-only. CHANGELOG under the unreleased 0.5.0.

Test plan

  • TDD: specs for the config keys, check --ractorize (exit 2 cases, setup stage, skip), the runner's skip gates, the request layer (serve inside a Ractor, root cause, swallowed exception), the gemfile override, and both workflows. Each was watched failing first. The gemfile spec also fails if the override is removed (Bundler: "You cannot specify the same gem twice").
  • Fixture specs in the lane: two violating apps fail, naming file and line, and the conforming app passes (3 examples, 0 failures).
  • bundle exec rake on Ruby 3.4.2: 243 examples, 0 failures; RuboCop clean.
  • bundle exec rspec on 4.0.7 (243, 0 failures) and 3.2.8 (242, 0 failures). The lane's full suite: 243, 0 failures.
  • The kit's strict self-audit stays clean with the runner in audit.require.
  • Released Rails 8.1.4 on Ruby 4.0.7: check --ractorize skips ("ActiveSupport::Ractors is unavailable"), exit 0.
  • The Ractorize lane on this PR's CI (first live run).
  • The consumer job: first live run when phlex-reactive sets ractorize.app (the issue's last criterion, a follow-up PR there).

fable-validator: PASS WITH NOTES. I fixed three notes in the second commit: the skip ordering, the KeyError in the consumer step, and the README frame.

Accepted risks:

  • A 4xx passes. A typo'd request path prints 404 and never exercises the render path; an auth redirect is a legitimate answer.
  • refs/pull/5/head moves with that PR and there is no committed lock, so both lanes re-resolve every run. Expected while report-only.
  • The consumer job runs on ractor.ruby. A repo on 3.4 bundles Rails main and then skips.
  • "500 (0 bytes)" in the render fixture spec depends on Rails' default error body (lane only).

Not verified:

  • Both Actions jobs live. The lane runs on this PR; the consumer job runs once a consumer adopts it.
  • The README's Active Record advice (db:schema:cache:dump, check_schema_cache_dump_version = false): no Active Record fixture here. It comes from the issue and Rails' ractors_test.rb, not from a run in this PR.

Deviations & judgment calls

  • Judgment: the Gemfile overrides ship as a static config/ractorize.gemfile (copied to Gemfile.ractorize) instead of a generated file; it drops the repo's own lines for rails/rack/i18n and Rails' component gems (Bundler refuses a gem declared twice), then declares the branches. The kit's lane uses the same file, so the consumer path is the one exercised.
  • Judgment: ractorize.audit_namespaces defaults to audit.namespace.
  • Judgment: added ractorize.setup (a command run in the app directory with RAILS_ENV=production before boot: schema load, schema cache dump, asset precompile); ractor.env applies to the ractorize stages too, so one env map per repo.
  • Discovery: Ruby 4.0's own message for an unshareable block names its file:line (Proc's self is not shareable: #<Proc:... initializer.rb:3>), so the root cause alone names the culprit for boot failures.
  • Discovery: the request env can be built on the main Ractor and its rack.input/rack.errors StringIOs created inside the request Ractor, so the runner needs neither Rack::MockRequest nor rack#2523.
  • Deviation: i18n is pinned to Make I18n.fallbacks and INTERPOLATION_PATTERN readable from non-main Ractors Shopify/i18n#5's head (ref: "refs/pull/5/head" on Shopify's repo), not the ractor_support branch. Discovery behind it: ActiveSupport always requires i18n/backend/fallbacks, so ActionView::LookupContext reads I18n.fallbacks (a class variable on the branch) on every render, and every request in a Ractor fails on it. On the branch the job would fail every consumer whatever their gem does. Switch back to branch: "ractor_support" when conformance-kit 0.2: per-repo settings in .conformance.yml, one org-required conformance.yml workflow #5 merges.
  • Discovery: config.enable_reloading defaults to true outside a generated production.rb, and the i18n railtie then registers a reloader that can't be made shareable: boot fails under :raise. Fixture apps set it to false; the README lists it.
  • Discovery: with the app's default show_exceptions, an exception inside the Ractor comes back as a 500 with an empty body; ShowExceptions keeps it in env["action_dispatch.exception"], so the runner reports it from there instead of changing the app's config.
  • Judgment: a request fails on an exception or a 5xx only; 4xx and redirects are reported with their status but pass (an auth redirect is a legitimate answer).
  • Judgment: the fixture apps' .conformance.yml allowlist Rails::Engine's own @called_from/@initializers with an ancestor: rule, as a real engine's would; the audit otherwise reports them on every engine.
  • Judgment: the violating fixture is two apps (boot and render), since the route-block violation stops the boot before any request runs.
  • Discovery: attr_reader is a C method, so a render violation's first frame is the line that calls the reader (the controller), not the attr_reader line.

https://claude.ai/code/session_01JCaQrTh5yGHrueUmq69PKs


Summary by cubic

Adds conformance-kit check --ractorize, which boots a gem's test app the way Rails 8.2 runs an app across Ractors: production boot with ActiveSupport::Ractors.unshareable_proc_action = :raise, then Rails.application.ractorize!, each ractorize.requests path served inside a non-main Ractor, and the audit run in the same process. The reusable workflow gains a report-only Ractorize job, and the kit is proven against fixture apps on Rails main by a report-only "Ractorize lane" in this repo's CI.

New Features

  • check --ractorize runs ractorize.setup, then the boot/request/audit runner in its own process; it skips with the reason below Ruby 4.0 or on a Rails without ractorize!, exiting 0.
  • A failing request or 5xx names the root cause and its first frame, including an exception the app's error page swallowed.
  • New .conformance.yml keys ractorize.app, .requests, .audit_namespaces and .setup, validated like the rest: an unknown key exits 2, and so does --ractorize without an app or its config/environment.rb.
  • config/ractorize.gemfile swaps Rails main, Rack main and Make I18n.fallbacks and INTERPOLATION_PATTERN readable from non-main Ractors Shopify/i18n#5 into a repo's Gemfile, dropping the repo's own lines for them and for Rails' component gems because Bundler refuses a duplicate.

Report-only rollout (until Rails 8.2)

  • conformance.yml gains a Ractorize job only when ractorize.app is set, continue-on-error and not a required check, so no existing job changes.
  • main.yml gains the Ractorize lane that runs the fixture specs on Ruby 4.0 with that gemfile: a boot violation and a render violation must fail naming file and line, and a conforming engine must pass.

Written for commit 4ae888c. Summary will update on new commits.

View guided diff Turn on auto-fix

…ails main

`conformance-kit check --ractorize` boots `ractorize.app` the way Rails
8.2 runs an app across Ractors: in production, with
`ActiveSupport::Ractors.unshareable_proc_action = :raise`, then
`Rails.application.ractorize!` and `require "i18n/ractorize"`. Each
`ractorize.requests` path is served inside a non-main Ractor; a failure
names the root cause and its first frame, also when the app's error page
swallowed it (env["action_dispatch.exception"]). Then the audit runs on
`ractorize.audit_namespaces` (default audit.namespace) in the same
process. It skips with the reason below Ruby 4.0 or on a Rails without
ractorize!.

- New .conformance.yml keys: ractorize.app, .requests,
  .audit_namespaces, .setup (validated like the rest: unknown key exits 2).
- config/ractorize.gemfile: the repo's Gemfile with Rails main, Rack main
  and Shopify/i18n#5 swapped in (it drops the repo's own lines for them,
  since Bundler refuses a gem declared twice).
- conformance.yml: a Ractorize job, only when ractorize.app is set,
  continue-on-error until Rails 8.2 ships ractorize!.
- main.yml: a report-only "Ractorize lane" (Ruby 4.0, the same gemfile)
  runs the fixture specs: a boot violation (a route block appended from
  an initializer) and a render violation (a shallow-frozen class ivar)
  must fail, naming the file and line; a conforming engine must pass.

Refs #22

Claude-Session: https://claude.ai/code/session_01JCaQrTh5yGHrueUmq69PKs
From the fable-validator pass:

- The runner checks Rails::Application#ractorize! before setting
  unshareable_proc_action = :raise, so a Rails that has
  ActiveSupport::Ractors but no ractorize! skips instead of failing its
  boot. Checked end to end on released Rails 8.1.4: skipped, exit 0.
- conformance.yml: a repo whose bundle lacks conformance-kit gets an
  ::error naming the Gemfile line, not a KeyError backtrace.
- README: the example frame is an absolute path, as the runner prints it.

Refs #22

Claude-Session: https://claude.ai/code/session_01JCaQrTh5yGHrueUmq69PKs
@mhenrixon mhenrixon added enhancement New feature or request devops CI, the release kit, Docker, deploys and infrastructure config Shared RuboCop configs and the reusable CI workflows labels Oct 19, 2026
@mhenrixon mhenrixon moved this to Needs us in Ractor-safe Rails Oct 19, 2026
@mhenrixon
mhenrixon merged commit 0ad889b into main Oct 19, 2026
10 checks passed
@mhenrixon mhenrixon moved this from Needs us to Done in Ractor-safe Rails Oct 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

config Shared RuboCop configs and the reusable CI workflows devops CI, the release kit, Docker, deploys and infrastructure enhancement New feature or request

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

ractorize job: boot the gem's test app with Rails.application.ractorize! and serve requests inside a Ractor

1 participant