Add Ractor mode - #2978
Draft
ericproulx wants to merge 1 commit into
Draft
Add Ractor mode#2978ericproulx wants to merge 1 commit into
ericproulx wants to merge 1 commit into
Conversation
Danger ReportNo issues found. |
ericproulx
marked this pull request as draft
September 21, 2026 08:45
ericproulx
force-pushed
the
ractor-mode
branch
from
September 21, 2026 10:42
fb92651 to
752b3be
Compare
A Grape API is defined and compiled in the main Ractor -- a non-main one cannot write a class instance variable at all, and that is where the DSL keeps everything -- so the only shape this can take is: define, compile, freeze, then serve from as many Ractors as the application wants. Grape.ractor! turns the mode on, before the API classes load, because it decides how a route block becomes a method: Ruby refuses to call a method defined from an unshareable Proc from another Ractor, so in this mode the blocks are isolated as they are read, and one that captures something unshareable says so as its class loads. It needs Ruby 4.0 or later, and says so rather than leaving an API half-frozen: 3.3 and 3.4 cannot make a Method shareable, and an API is full of them -- every endpoint holds its route block as one, and dry-types builds its coercers out of them. MyAPI.finalize! then compiles the API, makes the whole graph behind it shareable, and settles the process-wide state a request reads: Grape's own configuration (read once per setting first, or dry-configurable's memoization would write to a frozen Hash), the tables and options Rack, ActiveSupport, Builder and the json gem fill as they load, and a snapshot of the grape translations, since I18n keeps its configuration in class variables that a non-main Ractor may not read at all. Instrumentation is skipped in this mode for the same reason. What it gives up is in the README: instrumentation, per-request locale, file uploads (Ruby's own Tempfile is a Delegator, whose methods cannot be called from a non-main Ractor) and reloading. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ericproulx
force-pushed
the
ractor-mode
branch
from
September 21, 2026 11:06
752b3be to
3962cbb
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A Grape API is defined and compiled in the main Ractor — a non-main one cannot write a class instance variable at all, and that is where the DSL keeps everything — so the only shape this can take is: define, compile, freeze, then serve from as many Ractors as the application wants.
Grape.ractor!comes first because it decides how a route block becomes a method: Ruby refuses to call a method defined from an unshareableProcfrom another Ractor, so in this mode the blocks are isolated as they are read, and one that captures something unshareable says so as its class loads rather than on the first request.MyAPI.finalize!compiles the API, makes the whole graph behind it shareable, and settles the process-wide state a request reads — see the newGrape::Util::Shareable:FrozenErrorinstead of a value.grapetranslations, because I18n keeps its configuration in class variables, which a non-main Ractor may not read at all.Instrumentation is skipped in this mode for the same reason:
ActiveSupport::Notificationskeeps its notifier where a non-main Ractor cannot reach it.What it gives up
Documented in the README: instrumentation, per-request locale, file uploads and reloading.
The upload one is not Grape's to fix. Ruby's own
Tempfileis aDelegator, and a delegated method cannot be called from a non-main Ractor, so Rack's multipart parser raisesdefined with an un-shareable Proc in a different Ractorthe moment it writes a part's body.Evidence
A 20-shape parity matrix — query strings, nested and array params, form and JSON bodies, uploads, headers, cookies,
route_param, redirects,error!, a raised error throughrescue_from, XML, helpers, a mounted API, a validation failure, 404, DELETE, OPTIONS — run once in the main Ractor and once in another, comparing status, selected headers and body. 19 of 20 are identical; the twentieth is the upload above.Three of the frozen tables were found by that matrix rather than by reading code:
Rack::QueryParser::COMMON_SEP(any non-empty query string),Rack::Request::Helpers::FORM_DATA_MEDIA_TYPES(form posts) andActiveSupport::XmlMini::TYPE_NAMESwithBuilder::XChar(xml).9 specs in
spec/grape/util/shareable_spec.rb. They run in subprocesses:finalize!freezes process-wide state that the rest of the suite goes on writing to. One of them serves 100 requests across 4 Ractors and asserts a single distinct answer.Ruby support
Ractor mode needs Ruby 4.0 or later, and
Grape.ractor!raises on anything earlier rather than leaving an API half-frozen. Ruby 3.3 and 3.4 cannot make aMethodor anUnboundMethodshareable, and an API is full of them: every endpoint holds its route block as one, and dry-types builds its coercers out of them. Not even a trivial API can be finalized there.The specs skip on those versions through a capability check rather than a version comparison, so this follows Ruby rather than a hard-coded number. Verified locally on 3.3.12, 3.4.9 and 4.0.6, and against the
rails_7_2,rails_8_0andrack_2_2gemfiles.Open questions
DEPENDENCY_GLOBALSshrinking as they land.finalize!does two jobs — compile-and-freeze this API, and settle the process — and may deserve splitting.🤖 Generated with Claude Code