Skip to content

Add Ractor mode - #2978

Draft
ericproulx wants to merge 1 commit into
masterfrom
ractor-mode
Draft

ericproulx wants to merge 1 commit into
masterfrom
ractor-mode

Conversation

@ericproulx

@ericproulx ericproulx commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

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.

# config.ru
require 'grape'
Grape.ractor!

require_relative 'api'

run MyAPI.finalize!

Grape.ractor! comes first 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 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 new Grape::Util::Shareable:

  • Grape's own configuration. Every setting is read once and only then frozen: dry-configurable memoizes a setting on first read, so a config frozen cold answers the first read with a FrozenError instead of a value.
  • The lookup tables Rack, ActiveSupport and Builder fill as they load and only read afterwards. By module rather than by name, so a table added upstream is covered too.
  • A snapshot of the grape translations, 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::Notifications keeps 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 Tempfile is a Delegator, and a delegated method cannot be called from a non-main Ractor, so Rack's multipart parser raises defined with an un-shareable Proc in a different Ractor the 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 through rescue_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) and ActiveSupport::XmlMini::TYPE_NAMES with Builder::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 a Method or an UnboundMethod 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. 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_0 and rack_2_2 gemfiles.

Open questions

  • Grape freezes globals belonging to Rack, ActiveSupport, Builder and the json gem. It is read-only data and it works, but it is reach. The better long-term answer is upstream PRs freezing them, with DEPENDENCY_GLOBALS shrinking as they land.
  • finalize! does two jobs — compile-and-freeze this API, and settle the process — and may deserve splitting.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Sep 21, 2026

Copy link
Copy Markdown

Danger Report

No issues found.

View run

@ericproulx
ericproulx marked this pull request as draft September 21, 2026 08:45
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant