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

Merged
MH
1mo ago
+1012-11

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 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 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 #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 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.