Guide

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):

TierWhat it means hereChecked by
1 thread-safeNo unguarded shared mutable state on the request or broadcast pathEvery rubocop-thread_safety cop, no excludes; thread-contention examples over rendering, tokens, params and broadcasts
2 fiber-safeRequest state is fiber-local; the same work runs inside one Async reactorThe Falcon browser lanes; the Async examples
3 Ractor-cleanEvery setting is Ractor-shareable after boot, no class is written on the request path, caches are per-Ractorconformance-audit --strict; the Ruby 4.0 Ractor canary
4 compiler-probedSpinel and Roundhouse readiness is measured, never gatedThe 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_errors in an initializer or an after_initialize block; 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 main as its self, which no Ractor can share: it keeps working on the main Ractor. Write a caster (or error_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.

config/initializers/phlex_reactive.rb
# 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::NotAuthorizedError

Ractor 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_CACHE on every element with attributes; a per-Ractor cache is yippee-fun/phlex#1055.
  • Signing and verifying tokens. ActiveSupport::MessageVerifier cannot be built or used outside the main Ractor (its codec's class attributes and Messages::Metadata::ENVELOPE_SERIALIZERS are 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.