Concurrency & Ractors
phlex-reactive is thread-safe under Puma, fiber-safe under Falcon, and adds nothing of its own that stops a Ractor: its configuration is finalized at boot and its caches are per-Ractor. A whole app in Ractor mode still waits on Rails, ActiveSupport and Phlex.
What is held, and how#
phlex-reactive is held to the zoolutions concurrency tiers, each with a mechanical gate in CI (conformance-kit):
| Tier | What it means here | Checked by |
|---|---|---|
| 1 thread-safe | No unguarded shared mutable state on the request or broadcast path | Every rubocop-thread_safety cop, no excludes; thread-contention examples over rendering, tokens, params and broadcasts |
| 2 fiber-safe | Request state is fiber-local; the same work runs inside one Async reactor | The Falcon browser lanes; the Async examples |
| 3 Ractor-clean | Every setting is Ractor-shareable after boot, no class is written on the request path, caches are per-Ractor | conformance-audit --strict; the Ruby 4.0 Ractor canary |
| 4 compiler-probed | Spinel and Roundhouse readiness is measured, never gated | The conformance workflow's report-only probes |
Request state is fiber-local#
Everything that belongs to one request lives in Thread.current[], which Ruby keeps per fiber: the acting client's connection id (with_connection_id), the requesting host (with_url_options), the defer binding, and the verify_authorized tracking window. Under Falcon every request is a fiber, so two requests on one thread never see each other's state, and nothing has to be reset between them.
The off-request view context and the Turbo stream builder are cached the same way. Puma builds them once per thread; Falcon once per request, because each request is a new fiber. That rebuild is measured (benchmark/micro/view_context.rb, Ruby 4.0.6): about 14 µs and 36 objects for the view context, and 863 objects for a whole action round trip in a new fiber against 810 with warm caches. That is a few percent of a round trip, so the cache stays per fiber: a view context shared by two fibers on one thread would interleave their output, since a render that waits on the database yields mid-render under Falcon.
The configuration is finalized at boot#
Once the app has booted, the engine calls Phlex::Reactive.finalize!. It runs after your config/initializers and after every after_initialize block you registered, so they can still set and append. It freezes the param-type and named-schema registries and stores every setting as a deep-frozen, Ractor-shareable copy: the objects you handed in are not frozen.
What that changes for you:
- Lists are frozen after boot. Append to
authorization_errorsin an initializer or anafter_initializeblock; after boot, assign a new list. Writers keep working, and what they write is shared too. - A Proc is shared only when it can be. A block written in an initializer has
mainas itsself, which no Ractor can share: it keeps working on the main Ractor. Write a caster (orerror_flash) as a capture-free lambda in a module body to make it shareable. - Nothing you configure makes boot raise. A logger, an APM object or a lambda that closes over app state is kept as it is and works on the main Ractor only.
- The verifier is never shared. It is an
ActiveSupport::MessageVerifier, which ActiveSupport cannot build or use outside the main Ractor yet. - Give the renderer by name to have it follow a code reload in development; a class keeps rendering the class object it was given.
Booting phlex-reactive without the engine? Call Phlex::Reactive.finalize! at the end of your own boot.
# Shareable: self is the module, and nothing is captured.
module ReactiveTypes
MONEY = ->(value) { BigDecimal(value.to_s) rescue Phlex::Reactive::ParamSchema::DROP }
end
Phlex::Reactive.param_type(:money, &ReactiveTypes::MONEY)
Phlex::Reactive.renderer = "ApplicationController"
Phlex::Reactive.authorization_errors << Pundit::NotAuthorizedErrorRactor mode#
A non-main Ractor can read the configuration once it is finalized. For the request path to write no class it also needs eager loading (the production default): with it, the engine prepends verify_authorized's interceptor to every component and resolves every component's registries and render memos at boot.
What runs inside a non-main Ractor today, on Ruby 4.0 (the gem's Ractor canary): param coercion through a compiled schema, Phlex::Reactive::Stream wrapping, Streamable#dom_id, reading the finalized configuration, and upgrading a verified token payload.
What waits upstream:
- Rendering. Phlex 2.4.1 reads its unshareable
ATTRIBUTE_CACHEon every element with attributes; a per-Ractor cache is yippee-fun/phlex#1055. - Signing and verifying tokens.
ActiveSupport::MessageVerifiercannot be built or used outside the main Ractor (its codec's class attributes andMessages::Metadata::ENVELOPE_SERIALIZERSare not shareable). - The endpoint. Rails controllers keep class state of their own, and Rails' Ractor support is still landing (rails/rails#58306 and its follow-ups).
CI's report-only kino smoke serves the dummy app with kino -m ractor through ractor-rails-shim and records the first blocker it meets.
Checking your app#
bin/rails phlex_reactive:doctor reports Ractor readiness as an advisory line: whether eager loading is on, whether the configuration is finalized, and which settings hold a value a non-main Ractor cannot read (an around_action block written in an initializer, say). It never fails the doctor.