ractor-rails-shim 0.2.5 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +262 -0
- data/README.md +79 -17
- data/lib/ractor_rails_shim/fallback_ies.rb +20 -6
- data/lib/ractor_rails_shim/patches/action_dispatch.rb +12 -16
- data/lib/ractor_rails_shim/patches/action_view.rb +1 -1
- data/lib/ractor_rails_shim/patches/activerecord.rb +8 -38
- data/lib/ractor_rails_shim/patches/callables.rb +155 -0
- data/lib/ractor_rails_shim/patches/class_attribute.rb +85 -84
- data/lib/ractor_rails_shim/patches/core.rb +182 -107
- data/lib/ractor_rails_shim/patches/devise.rb +1 -1
- data/lib/ractor_rails_shim/patches/kaminari.rb +1 -6
- data/lib/ractor_rails_shim/patches/make_shareable.rb +177 -231
- data/lib/ractor_rails_shim/patches/mattr_accessor.rb +34 -27
- data/lib/ractor_rails_shim/patches/rails_module.rb +2 -2
- data/lib/ractor_rails_shim/patches/route_helpers.rb +1 -1
- data/lib/ractor_rails_shim/patches.rb +38 -0
- data/lib/ractor_rails_shim/version.rb +1 -1
- data/lib/ractor_rails_shim/version_check.rb +10 -6
- data/lib/ractor_rails_shim/version_policy.rb +72 -0
- metadata +7 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 21002fee5a30f663904441d8c016c035564095bf456048a25917657e9adab5fc
|
|
4
|
+
data.tar.gz: dcb6764139de152ae7b958112503efc7951d59ae211c04ee268da6bf63a2be51
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 28cf75ae0f4972142cee3965d5c68fd8b70c09b901dc60c186f178ac403fc7695c3f7e2e77e4591c9aa6aa0fc9c6d1ba5eafa8e3c64d5abad545b50159159d6c
|
|
7
|
+
data.tar.gz: b1bf30685c4b7082c5930a63693571e4d3affff2fa0b530fd355aaf5a57f5cf42280ff5a590f099705e8af573ae953d654ace88fbb813dc05e463379fa0c09c7
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,268 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.3.0]
|
|
11
|
+
|
|
12
|
+
### Changed — breaking (public API)
|
|
13
|
+
- **`worker_app` renamed to `worker_app!`.** The factory freezes +
|
|
14
|
+
`Ractor.make_shareable`s the `WorkerApp` — a destructive build — so it
|
|
15
|
+
now carries the bang the naming convention reserves for "mutates /
|
|
16
|
+
produces a frozen shareable." Callers following the README's
|
|
17
|
+
`Ractor.new(worker_app) { |a| a.call(env) }` example must update to
|
|
18
|
+
`worker_app!`. The old name is gone. (`patches/core.rb`)
|
|
19
|
+
|
|
20
|
+
- **`fix_url_helpers_singleton_routes` renamed to
|
|
21
|
+
`fix_url_helpers_singleton_routes!`.** Mutates the global routes
|
|
22
|
+
singleton; now bang-suffixed per the convention documented at the top
|
|
23
|
+
of `patches.rb`. (`patches/route_helpers.rb`)
|
|
24
|
+
|
|
25
|
+
- **Tightened `activesupport` dependency to `>= 8.1`.** The gemspec
|
|
26
|
+
previously declared `>= 7.0`, but the shim only supports Rails 8.1
|
|
27
|
+
(per `Version::TESTED_RAILS` / `SUPPORTED_RAILS`). The looser bound let
|
|
28
|
+
Bundler resolve against AS 7.x where the class-layout patches
|
|
29
|
+
(`class_attribute`, `Callbacks`, `PathRegistry`, …) would silently miss
|
|
30
|
+
blockers or redefine the wrong methods. Bound at `>= 8.1` so Bundler
|
|
31
|
+
fails fast on an unsupported Rails. (`ractor-rails-shim.gemspec`)
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
- **`_apply_shareable_constants!` set the done-flag on undefined
|
|
35
|
+
constants.** It set `@shareable_constants_done = true` after the first
|
|
36
|
+
run unconditionally, even when registered constants didn't exist yet
|
|
37
|
+
(`make_constant_shareable` returned `false`). A later call (from
|
|
38
|
+
`make_app_shareable!` or `prepare_for_ractors!`) short-circuited on the
|
|
39
|
+
flag and never retried the now-loadable constants — workers then hit
|
|
40
|
+
`Ractor::IsolationError` on unshareable values (e.g.
|
|
41
|
+
`Rack::Utils::PATH_SEPS`, a Regexp not deep-frozen until
|
|
42
|
+
`make_shareable` runs on it). The flag now sets only when every
|
|
43
|
+
registered constant was made shareable; any `false` return leaves it
|
|
44
|
+
unset so the next call retries. Caught by the integration spec (boots
|
|
45
|
+
a real Rails 8.1 app, dispatches `GET /up` in a worker Ractor); pinned
|
|
46
|
+
by `apply_shareable_constants_retry_spec.rb`.
|
|
47
|
+
|
|
48
|
+
- **ActionFilter private ivar reads now gated through the `_swallow`
|
|
49
|
+
debug funnel.** The eval'd `set_callback` interceptor inlined
|
|
50
|
+
`af.instance_variable_get(:@conditional_key) rescue nil`, but
|
|
51
|
+
`instance_variable_get` returns nil for a missing ivar *without*
|
|
52
|
+
raising — the rescue never fired, and a silent Rails internal rename
|
|
53
|
+
(`@conditional_key` / `@actions`) would leave `only`/`except` nil,
|
|
54
|
+
making callbacks run for actions they shouldn't (security-relevant)
|
|
55
|
+
with no visible cause. New `_read_action_filter_constraints(af)` checks
|
|
56
|
+
`instance_variable_defined?` and emits a labeled
|
|
57
|
+
`[ractor_rails_shim] action filter constraints: missing ivar …`
|
|
58
|
+
warning under `debug=true`. Returns `[nil, nil]` for a bare object,
|
|
59
|
+
`[key, symbols]` when the ivars are present.
|
|
60
|
+
|
|
61
|
+
- **Dead ternary in thread-mode `class_attribute` reader.** The reader
|
|
62
|
+
carried `:__class_attr___callbacks == :__callbacks ? {} : nil`, but
|
|
63
|
+
the namespaced name is always `__class_attr_<name>` (never `<name>`),
|
|
64
|
+
so the check was always false — dead code that allocated a `Symbol`
|
|
65
|
+
via `inspect` on every read AND never delivered the intended `{}`
|
|
66
|
+
default for `__callbacks` (callers index the result, so `nil` would
|
|
67
|
+
`NoMethodError`). Replaced with a static decision based on the public
|
|
68
|
+
name, inlining the shared frozen `EMPTY_CALLBACKS_HASH` constant.
|
|
69
|
+
|
|
70
|
+
- **Redundant `class_variable_set` pair in `mattr_accessor` writer.**
|
|
71
|
+
The shim-generated writer had two complementary guarded lines
|
|
72
|
+
(`set if defined?` + `set unless defined?`) that together were one
|
|
73
|
+
unconditional set, with a wasted `class_variable_defined?` per write.
|
|
74
|
+
Replaced with a single `class_variable_set(cv, val) if Ractor.main?`.
|
|
75
|
+
|
|
76
|
+
- **`WorkerApp#setup_once!` race-spec cleanup leak.** The race spec's
|
|
77
|
+
`ensure` block restored `Thread::Mutex.new` via
|
|
78
|
+
`define_method { orig_new.call }`, leaving a block that captures a
|
|
79
|
+
main-Ractor-bound `Method`. When a later spec's worker Ractor called
|
|
80
|
+
`Thread::Mutex.new` it raised `IsolationError` (flaky
|
|
81
|
+
`WorkerAppSpec#test_0002`). Replaced with `remove_method(:new)` so the
|
|
82
|
+
call falls back to the class-defined `new` (no captured binding).
|
|
83
|
+
|
|
84
|
+
### Changed
|
|
85
|
+
- **Centralized `const_set`-with-suppressed-`$VERBOSE` into
|
|
86
|
+
`_reassign_shareable_const(name, value)`.** `SHAREABLE_FALLBACK`,
|
|
87
|
+
`SHAREABLE_MATTR_DEFAULTS`, `SHAREABLE_APP`, and
|
|
88
|
+
`SHAREABLE_DECLARED_CALLBACKS` were each rebuilt + reassigned via an
|
|
89
|
+
inlined `$VERBOSE = nil … const_set … ensure $VERBOSE = …` dance. The
|
|
90
|
+
dance now lives in one helper on `RactorRailsShim`; all rebuild sites
|
|
91
|
+
(including the 6 `activerecord.rb` sites + 1 `kaminari.rb` site) call
|
|
92
|
+
it. The constants stay frozen shareable `Hash`es (always readable from
|
|
93
|
+
any Ractor) — a mutable-then-frozen registry would not be shareable
|
|
94
|
+
until `freeze!`, breaking workers that read early.
|
|
95
|
+
|
|
96
|
+
- **Callable/lock object model extracted to `patches/callables.rb`.**
|
|
97
|
+
`NoOpProc` / `Callable` / `CallableConst` / `DeviseMappingSnapshot` /
|
|
98
|
+
`NoOpLock` / `NoOpLogDev` + the `_devise_mapping_snapshot` helper moved
|
|
99
|
+
out of the 964-line `make_shareable.rb` into their own file. Plain
|
|
100
|
+
class definitions replace the string-eval indirection (the eval was
|
|
101
|
+
stylistic, not behavioral). `make_shareable.rb`: 964 → 822 lines.
|
|
102
|
+
|
|
103
|
+
- **`VersionPolicy` module extracted from the `RactorRailsShim` god
|
|
104
|
+
module.** The version-policy + patch-registry concern (`:warn` /
|
|
105
|
+
`:strict` / `:off` switch, `PATCH_VERSIONS`, `_register_patch` /
|
|
106
|
+
`applicable_patches` / `_version_mismatch`) moved from the
|
|
107
|
+
`RactorRailsShim` singleton in `core.rb` to
|
|
108
|
+
`RactorRailsShim::VersionPolicy` (`version_policy.rb`). `core.rb` keeps
|
|
109
|
+
the public facade (`RactorRailsShim.version_policy`, `.applicable_patches`,
|
|
110
|
+
`::PATCH_VERSIONS`, `::UnsupportedVersionError`) delegating to the
|
|
111
|
+
module. Orthogonal to the already-extracted `RactorRailsShim::Version`
|
|
112
|
+
(detection).
|
|
113
|
+
|
|
114
|
+
- **`mattr_accessor` split into single-responsibility helpers.** The
|
|
115
|
+
per-symbol body did four things (call super, push to `CLASS_ATTRIBUTES`,
|
|
116
|
+
seed the default + rebuild the shareable constant, redefine the
|
|
117
|
+
reader/writer). Extracted `_seed_mattr_default(key, default)` and
|
|
118
|
+
`_register_for_fallback(mod_name, sym, key, default)`; `mattr_accessor`
|
|
119
|
+
now reads `super, register, seed, redefine`.
|
|
120
|
+
|
|
121
|
+
- **Thread-mode vs Ractor-mode `class_attribute` heredocs de-duplicated.**
|
|
122
|
+
Each branch inlined a near-identical ~25-line reader/writer heredoc,
|
|
123
|
+
differing only in the method name. Extracted
|
|
124
|
+
`_class_attr_thread_methods` / `_class_attr_ractor_methods` (each builds
|
|
125
|
+
one reader+writer pair); `redefine` calls the helper twice. One source
|
|
126
|
+
of truth per mode. Also fixed a misaligned `else` (12-space indent in a
|
|
127
|
+
10-space block). `class_attribute.rb`: 232 → 224 lines.
|
|
128
|
+
|
|
129
|
+
- **Freeze-path bare rescues routed through the `_swallow` debug funnel.**
|
|
130
|
+
`_freeze_shareable_class_ivars!`, `_freeze_declared_callbacks!`,
|
|
131
|
+
`_collect_controller_classes`, `_replace_locks_and_concurrent_maps!`,
|
|
132
|
+
`_replace_one_proc`, `_neutralize_logger_io!`,
|
|
133
|
+
`DeviseMappingSnapshot`, `_devise_mapping_snapshot`, `_warm_journey_routes`,
|
|
134
|
+
url-helper freeze, `DUMMY_END_NODE`, `MimeNegotiation`, AR db-config
|
|
135
|
+
handlers, AR query transformers, devise mappings, and the view-context
|
|
136
|
+
fallback now emit a labeled `[ractor_rails_shim] <label>: …` line to
|
|
137
|
+
`$stderr` under `debug=true` instead of bare `rescue; nil`. Silent by
|
|
138
|
+
default (backward compatible).
|
|
139
|
+
|
|
140
|
+
- **`fallback_ies.rb` moved into the `RactorRailsShim` namespace.** It
|
|
141
|
+
previously defined `ActiveSupport::IsolatedExecutionState` directly
|
|
142
|
+
inside the `ActiveSupport` namespace (guarded by `defined?`). Defining
|
|
143
|
+
an upstream-namespaced constant from a third-party gem is a
|
|
144
|
+
namespace-patch smell — if a future AS lazy-loads IES, load order
|
|
145
|
+
decides which definition wins. The fallback now lives at
|
|
146
|
+
`RactorRailsShim::FallbackIES` and is aliased onto
|
|
147
|
+
`ActiveSupport::IsolatedExecutionState` only when the real AS IES is
|
|
148
|
+
absent. The ~270 string-eval'd references across the patch files
|
|
149
|
+
resolve to the alias when AS is absent and to the real one when present.
|
|
150
|
+
|
|
151
|
+
- **Stream-of-consciousness comments trimmed to one-line invariants.**
|
|
152
|
+
Working-notes-style comments that narrated history ("actually it IS a
|
|
153
|
+
constant holding…", "Pre-fix the traversals only handled…") replaced
|
|
154
|
+
with one-line invariant statements. The history is in git.
|
|
155
|
+
|
|
156
|
+
### Documentation
|
|
157
|
+
- **`WorkerApp#setup_once!` race comment corrected (again).** A prior
|
|
158
|
+
0.2.6 entry claimed `Ractor.current[:key] ||= Thread::Mutex.new` is
|
|
159
|
+
atomic in Ruby 4.0.6. It is NOT — `||=` is a read-then-write, and N
|
|
160
|
+
racing threads produce N distinct mutexes (verified by spec under a
|
|
161
|
+
widened window). The code is saved by (1) MRI's GIL serializing the
|
|
162
|
+
flag check inside `synchronize` and (2) `rebind_constants` /
|
|
163
|
+
`init_worker_ar_connections!` both being idempotent. Comment now states
|
|
164
|
+
the real contract + the escape hatch if either property changes. Two
|
|
165
|
+
specs pin the idempotency contract.
|
|
166
|
+
|
|
167
|
+
## [0.2.6]
|
|
168
|
+
|
|
169
|
+
### Fixed
|
|
170
|
+
- **`WorkerApp` from `worker_app` not `Ractor.shareable?`.** The factory
|
|
171
|
+
returned a bare `WorkerApp` instance without freezing or making it
|
|
172
|
+
shareable, so any caller following the README's
|
|
173
|
+
`Ractor.new(worker_app) { |a| a.call(env) }` example would raise
|
|
174
|
+
`Ractor::IsolationError` at spawn. `worker_app` now freezes +
|
|
175
|
+
`Ractor.make_shareable`s the instance, enforcing the shareability
|
|
176
|
+
contract at the boundary instead of pushing it onto every caller.
|
|
177
|
+
(`patches/core.rb`)
|
|
178
|
+
|
|
179
|
+
- **`Hash#compute_if_absent` nil-caching divergence from
|
|
180
|
+
`Concurrent::Map`.** The shim replaces `Concurrent::Map` instances in
|
|
181
|
+
the frozen app graph with plain `Hash`es (Concurrent::Map isn't
|
|
182
|
+
Ractor-shareable) and adds a compatible `compute_if_absent`. The
|
|
183
|
+
frozen-Hash branch used `IES[key] ||= yield`, which SKIPS nil results
|
|
184
|
+
— but `Concurrent::Map#compute_if_absent` STORES nil (verified: the
|
|
185
|
+
key is present after a nil-result block call), so the shim's
|
|
186
|
+
divergent semantics re-invoked the block on every subsequent lookup
|
|
187
|
+
for any cache slot whose computed value was nil. The frozen branch
|
|
188
|
+
now uses a two-level IES bucket (`slot[object_id][key]`) with a
|
|
189
|
+
proper `key?` presence check, matching `Concurrent::Map` exactly.
|
|
190
|
+
Also fixed: an existing key on a frozen Hash now returns without
|
|
191
|
+
invoking the block (was always treating the receiver as empty), and
|
|
192
|
+
per-Hash isolation is now keyed by `object_id` so two frozen hashes
|
|
193
|
+
with the same key don't collide. (`patches/core.rb`)
|
|
194
|
+
|
|
195
|
+
- **Magic `line == 32` strategy dispatch replaced with identity check.**
|
|
196
|
+
The Proc-replacement pass distinguished
|
|
197
|
+
`ActionDispatch::Routing::Mapper::Constraints::SERVE` from `CALL` by
|
|
198
|
+
hard-coding the `source_location` line number (32). Any Rails patch
|
|
199
|
+
release that shifted the constant definitions by even one line would
|
|
200
|
+
silently swap the two strategies and break routing (a dispatcher
|
|
201
|
+
endpoint called as `app.call(req.env)` instead of `app.serve(req)`).
|
|
202
|
+
Replaced with an `equal?` check against the actual constants — the
|
|
203
|
+
value stored in `@strategy` IS the constant object (Rails assigns it
|
|
204
|
+
by reference), so identity is the robust identifier, independent of
|
|
205
|
+
source layout. Falls through to `NoOpProc` for an unknown Proc
|
|
206
|
+
(defensive; never mis-routes). Extracted into
|
|
207
|
+
`_strategy_replacement_for`. (`patches/make_shareable.rb`)
|
|
208
|
+
|
|
209
|
+
- **`capture_app_constants` crash on non-Zeitwerk autoloaders.** The
|
|
210
|
+
method called `Rails.autoloaders.main` / `.once` directly, which
|
|
211
|
+
`NoMethodError`s for apps in classic-loader mode or with
|
|
212
|
+
`config.autoloaders = false` (the autoloaders object exists but
|
|
213
|
+
doesn't expose `main`/`once`). Now filters to the loaders that
|
|
214
|
+
actually expose `all_expected_cpaths` (the Zeitwerk introspection
|
|
215
|
+
API), and tolerates autoloaders objects that only respond to `each`.
|
|
216
|
+
Also fixed a pre-existing bug: the early-return path returned an
|
|
217
|
+
UNFROZEN map, violating the method's documented frozen-map contract.
|
|
218
|
+
(`patches/core.rb`)
|
|
219
|
+
|
|
220
|
+
### Changed
|
|
221
|
+
- **`_replace_unshareable_procs!` 3.times magic replaced with a
|
|
222
|
+
fixed-point loop.** The original `3.times` had no justification and
|
|
223
|
+
an arbitrary constant. Replaced with a loop-to-fixed-point (break
|
|
224
|
+
when `_collect_procs` returns empty) plus a safety cap of 8 passes
|
|
225
|
+
to guard against a pathological graph where replacement keeps
|
|
226
|
+
introducing new Procs (the replacements are `NoOpProc`/`Callable`,
|
|
227
|
+
not `Proc`, so this shouldn't happen, but the cap prevents an
|
|
228
|
+
infinite loop). Documented the multi-occurrence rationale (the same
|
|
229
|
+
Proc object can live in many containers, e.g. shared deprecation
|
|
230
|
+
behaviors). Observed real graphs converge in 2 passes.
|
|
231
|
+
(`patches/make_shareable.rb`)
|
|
232
|
+
|
|
233
|
+
- **Graph traversals now walk Set/Struct/Enumerable.**
|
|
234
|
+
`_collect_procs` and `_replace_locks_and_concurrent_maps!` only
|
|
235
|
+
handled `Array` and `Hash` as recursive containers. Rails uses `Set`
|
|
236
|
+
in several caches (e.g. `ActionDispatch::Journey::Routes`), and
|
|
237
|
+
`Struct`/`Enumerable` mixes appear in framework internals — a
|
|
238
|
+
`Mutex` or `Proc` nested inside one of those was missed and left
|
|
239
|
+
unshareable, breaking `make_app_shareable!` downstream. Extracted
|
|
240
|
+
`_each_ivar_and_child` (single responsibility: enumerate every child
|
|
241
|
+
reference of an object) shared by both traversals, handling `Hash`,
|
|
242
|
+
`Array`, `Set`, `Struct`, and a generic `Enumerable` fallback
|
|
243
|
+
(`Range`, `Enumerator`, custom mixins). The `Hash#default_proc`
|
|
244
|
+
branch is preserved so `_collect_procs` still flags default-proc
|
|
245
|
+
Procs. (`patches/make_shareable.rb`)
|
|
246
|
+
|
|
247
|
+
### Added
|
|
248
|
+
- **Debug funnel for freeze-path silent rescues.** The
|
|
249
|
+
freeze/shareability paths (`_freeze_active_record_class_ivars!`,
|
|
250
|
+
`_freeze_global_class_ivars!`, `_make_value_shareable`) rescue all
|
|
251
|
+
exceptions silently with bare `rescue; nil`. Individual failures are
|
|
252
|
+
expected (some ivars hold intrinsically unshareable values like
|
|
253
|
+
Procs), but a worker Ractor that later crashes on the same
|
|
254
|
+
unshareable value had no traceable cause. New `_swallow(label) { ... }`
|
|
255
|
+
funnel is silent by default (preserves backward compat) and emits a
|
|
256
|
+
labeled `[ractor_rails_shim] <label>: <class>: <msg>` line to
|
|
257
|
+
`$stderr` when `RactorRailsShim.debug = true`. The three freeze-path
|
|
258
|
+
rescues are routed through it; the bare rescues in non-freeze paths
|
|
259
|
+
(require `LoadError`, optional `const_get`, etc.) are left as-is —
|
|
260
|
+
those are genuinely expected and a `$stderr` line would be noise.
|
|
261
|
+
(`patches/core.rb`)
|
|
262
|
+
|
|
263
|
+
### Documentation
|
|
264
|
+
- **`WorkerApp#setup_once!` race comment corrected.** The previous
|
|
265
|
+
comment claimed `Ractor.current[:key] ||= Thread::Mutex.new` was racy
|
|
266
|
+
under "extreme contention" and dismissed it as harmless because the
|
|
267
|
+
init steps are idempotent. Verified on Ruby 4.0.6 that
|
|
268
|
+
`Ractor.current[:key] ||= X` is atomic (100 concurrent threads
|
|
269
|
+
produce exactly one mutex object), so the TOCTOU race does not
|
|
270
|
+
occur. Replaced the misleading comment with the verified finding.
|
|
271
|
+
|
|
10
272
|
## [0.2.5]
|
|
11
273
|
|
|
12
274
|
### Fixed
|
data/README.md
CHANGED
|
@@ -4,31 +4,34 @@ A monkey-patch shim that reroutes Rails' class-level instance variable accessors
|
|
|
4
4
|
|
|
5
5
|
**Status:** proof-of-concept / stopgap. The goal is for Rails to do this upstream, at which point this gem becomes a no-op and can be removed.
|
|
6
6
|
|
|
7
|
-
**Current status:** on a full Rails 8.1 app (Devise 5, Propshaft,
|
|
8
|
-
PG) under **official Ruby 4.0.6**, worker Ractors serve **every**
|
|
9
|
-
`GET /up`, full ERB view rendering, Devise sign-in/sign-out
|
|
10
|
-
**and** validation), and authenticated Devise **writes**
|
|
11
|
-
row persisted). A worker Ractor dispatches `GET /up` →
|
|
12
|
-
`RactorRailsShim.make_app_shareable!`. The shim builds a
|
|
13
|
-
table for framework class config (`class_attribute` /
|
|
14
|
-
and patches the raw class-ivar accessors Rails reads
|
|
15
|
-
(`ExecutionWrapper.active_key`, `Notifications.notifier`,
|
|
16
|
-
`PathRegistry`, `I18n`, `AbstractController` lazy ivars,
|
|
17
|
-
`ExecutionContext`, etc.).
|
|
7
|
+
**Current status (v0.2.5):** on a full Rails 8.1 app (Devise 5, Propshaft,
|
|
8
|
+
Kaminari, PG) under **official Ruby 4.0.6**, worker Ractors serve **every**
|
|
9
|
+
routable action — `GET /up`, full ERB view rendering, Devise sign-in/sign-out
|
|
10
|
+
(CSRF issuance **and** validation), and authenticated Devise **writes**
|
|
11
|
+
(`POST /posts` → 302, row persisted). A worker Ractor dispatches `GET /up` →
|
|
12
|
+
**HTTP 200** via `RactorRailsShim.make_app_shareable!`. The shim builds a
|
|
13
|
+
shareable fallback table for framework class config (`class_attribute` /
|
|
14
|
+
`mattr_accessor` values) and patches the raw class-ivar accessors Rails reads
|
|
15
|
+
per-request (`ExecutionWrapper.active_key`, `Notifications.notifier`,
|
|
16
|
+
`Inflections`, `PathRegistry`, `I18n`, `AbstractController` lazy ivars,
|
|
17
|
+
`Rack::Request`/`Utils`, `ExecutionContext`, etc.). 61 unit specs across 7
|
|
18
|
+
spec files pass (no Rails dependency); an integration spec dispatches `GET /up`
|
|
19
|
+
→ 200 in a worker Ractor.
|
|
18
20
|
|
|
19
21
|
**No patched Ruby or kino required.** Official Ruby 4.0.6 ships the frozen-iseq
|
|
20
22
|
call-cache fix (#22075) and the cross-ractor env-string fix, so the SIGBUS crashes
|
|
21
23
|
that previously required the DDKatch patched Ruby/kino forks are resolved in core.
|
|
22
24
|
Both SIGBUS classes were the only reason a patched build was ever needed; on 4.0.6
|
|
23
|
-
the shim runs `kino -m ractor` on the official `kino` gem under sustained
|
|
24
|
-
**and** write load (verified: 0 transport failures / 0 server errors across
|
|
25
|
+
the shim runs `kino -m ractor` on the official `kino` gem (0.1.3) under sustained
|
|
26
|
+
read **and** write load (verified: 0 transport failures / 0 server errors across
|
|
25
27
|
`/up`, `GET /posts`, `POST /posts` in the benchmark matrix).
|
|
26
28
|
|
|
27
29
|
## Requirements
|
|
28
30
|
|
|
29
|
-
- **Ruby >= 4.0** — the shim relies on Ruby 4.0's Ractor semantics and
|
|
30
|
-
`Ractor.make_shareable
|
|
31
|
-
|
|
31
|
+
- **Ruby >= 4.0.6** — the shim relies on Ruby 4.0's Ractor semantics and
|
|
32
|
+
`Ractor.make_shareable`, plus the frozen-iseq call-cache fix (#22075) and
|
|
33
|
+
the cross-ractor env-string fix that both shipped in 4.0.6. It will not
|
|
34
|
+
work (and refuses to install) on earlier Ruby versions.
|
|
32
35
|
- **Rails ~> 8.1** — tested against Rails 8.1.x class layouts. Other versions
|
|
33
36
|
are not yet supported (see Version compatibility below).
|
|
34
37
|
|
|
@@ -43,12 +46,71 @@ the configuration it is verified against.
|
|
|
43
46
|
[kino](https://github.com/yaroslav/kino) (`kino -m ractor`). On official Ruby
|
|
44
47
|
4.0.6 the **upstream `kino` gem (0.1.3) works as-is** — no fork or patch is
|
|
45
48
|
required. (Historically a DDKatch/kino fork carried a per-ractor env-string
|
|
46
|
-
cache fix; that fix
|
|
49
|
+
cache fix; that fix landed in official 4.0.6, so the fork is obsolete.)
|
|
50
|
+
|
|
51
|
+
**Threaded servers (Puma/Falcon/Thin/Webrick):** set
|
|
52
|
+
`RactorRailsShim.thread_mode = true` (or `ENV["SERVER"]` to one of
|
|
53
|
+
`puma|falcon|thin|webrick|thread*`) and the shim installs a **minimal**
|
|
54
|
+
patch set — only the `class_attribute` isolation fix + nil-safe callback
|
|
55
|
+
replay. The per-Ractor IES-routing patches are skipped because IES is empty
|
|
56
|
+
on a thread server's request threads and would break the app; Rails' own
|
|
57
|
+
class globals are thread-safe and used as-is. Use this when you want the
|
|
58
|
+
callback-correctness fixes without running in Ractor mode.
|
|
47
59
|
|
|
48
60
|
**Benchmarks:** throughput/latency/memory of this shim + the test app under
|
|
49
61
|
kino `:ractor` vs Puma vs Falcon are documented in
|
|
50
62
|
[ractor-rails-shim-test-app/BENCHMARKS.md](https://github.com/DDKatch/ractor-rails-shim-test-app/blob/main/BENCHMARKS.md).
|
|
51
63
|
|
|
64
|
+
## Repository layout
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
lib/ractor_rails_shim.rb # entry point — autoload-install if Rails is loaded
|
|
68
|
+
lib/ractor_rails_shim/
|
|
69
|
+
version.rb # VERSION constant (currently 0.2.5)
|
|
70
|
+
version_check.rb # Gem::Version-based Ruby/Rails detection + policy
|
|
71
|
+
fallback_ies.rb # thread-local IES shim when ActiveSupport is absent
|
|
72
|
+
check.rb # the ractor-rails-check audit (Check.scan / report)
|
|
73
|
+
patches.rb # requires all per-concern patch files in order
|
|
74
|
+
patches/
|
|
75
|
+
core.rb # module skeleton, registries, install, prepare_for_ractors!, WorkerApp
|
|
76
|
+
make_shareable.rb # make_app_shareable!, callable/lock replacement, shareable fallback
|
|
77
|
+
rails_module.rb # Rails.application / Rails.cache / Rails.logger / Rails.env …
|
|
78
|
+
mattr_accessor.rb # Module#mattr_accessor / cattr_accessor macro rewrite
|
|
79
|
+
class_attribute.rb # ActiveSupport class_attribute macro rewrite
|
|
80
|
+
zeitwerk_registry.rb # Zeitwerk::Registry class ivars
|
|
81
|
+
route_helpers.rb # ActionDispatch::Routing::RouteSet#generate_url_helpers
|
|
82
|
+
url_helpers.rb # URL helper singleton + module fixes
|
|
83
|
+
execution_wrapper.rb # ActiveSupport::ExecutionWrapper + callback replay
|
|
84
|
+
rack.rb # Rack::Request / Rack::Utils
|
|
85
|
+
action_view.rb # PathRegistry, LookupContext, Template handlers, compiled_method_container
|
|
86
|
+
action_controller.rb # AbstractController, ActionController name/encoding
|
|
87
|
+
action_dispatch.rb # ActionDispatch routing, http_url, journey, mounted helpers
|
|
88
|
+
polymorphic_routes.rb # polymorphic_path(s) URL helpers
|
|
89
|
+
active_support.rb # Inflector, error_reporter, ExecutionContext, I18n, JSON encoding, Reloader
|
|
90
|
+
warden.rb # Warden hooks / strategies / serializer
|
|
91
|
+
devise.rb # Devise url_helpers / authenticatable / failure_app
|
|
92
|
+
active_model_attribute.rb # ActiveModel::Attribute dup_or_share for frozen graphs
|
|
93
|
+
active_record_model_schema.rb # AR ModelSchema reload_schema_from_cache (worker-safe)
|
|
94
|
+
activerecord.rb # AR connection handler, configurations, query caches, …
|
|
95
|
+
kaminari.rb # Kaminari config
|
|
96
|
+
propshaft.rb # Propshaft asset server
|
|
97
|
+
orm_adapter.rb # orm_adapter
|
|
98
|
+
openssl.rb # OpenSSL digest cache
|
|
99
|
+
rubygems.rb # Rubygems msgpack pre-check
|
|
100
|
+
exe/ractor-rails-check # CLI audit tool
|
|
101
|
+
spec/ # 7 spec files, 61 unit tests (no Rails dep) + integration spec
|
|
102
|
+
script/make_test_app.sh # build the minimal Rails 8.1 test app (CI uses this)
|
|
103
|
+
script/make_full_test_app.sh # build the full-featured test app (Devise/PG/Kaminari)
|
|
104
|
+
.github/workflows/ci.yml # unit job + integration job (GET /up → 200 in a worker Ractor)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Per-concern patch files reopen `RactorRailsShim`'s singleton class to add
|
|
108
|
+
their `_install_*` methods. `patches.rb` requires them in dependency order
|
|
109
|
+
(`core.rb` first — it defines the module skeleton + registries the rest
|
|
110
|
+
reference). Each `_install_*` method is idempotent (guarded by its own
|
|
111
|
+
`@*_patched` flag), so `install`, `prepare_for_ractors!`, and
|
|
112
|
+
`make_app_shareable!` can all call the full set safely.
|
|
113
|
+
|
|
52
114
|
## Why
|
|
53
115
|
|
|
54
116
|
Rails stores global state in class-level instance variables:
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
# Fallback IsolatedExecutionState when ActiveSupport is not available.
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
# ActiveSupport::IsolatedExecutionState
|
|
7
|
-
|
|
8
|
-
|
|
4
|
+
#
|
|
5
|
+
# The shim's string-eval'd patch methods reference the literal constant
|
|
6
|
+
# `ActiveSupport::IsolatedExecutionState` (no captured binding → callable
|
|
7
|
+
# from any Ractor). To avoid opening the ActiveSupport namespace from a
|
|
8
|
+
# third-party gem, the fallback is defined in the shim's own namespace
|
|
9
|
+
# (RactorRailsShim::FallbackIES) and aliased onto
|
|
10
|
+
# ActiveSupport::IsolatedExecutionState only when the real AS IES is absent.
|
|
11
|
+
# When AS is loaded, the real one wins untouched and no alias is created.
|
|
12
|
+
module RactorRailsShim
|
|
13
|
+
module FallbackIES
|
|
9
14
|
KEY = :active_support_execution_state_fallback
|
|
10
15
|
|
|
11
16
|
class << self
|
|
@@ -30,4 +35,13 @@ module ActiveSupport
|
|
|
30
35
|
end
|
|
31
36
|
end
|
|
32
37
|
end
|
|
33
|
-
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Alias onto ActiveSupport::IsolatedExecutionState only when the real AS IES
|
|
41
|
+
# is absent, so the shim's string-eval'd references resolve to the fallback
|
|
42
|
+
# without the shim opening the ActiveSupport namespace to define a module.
|
|
43
|
+
unless defined?(ActiveSupport::IsolatedExecutionState)
|
|
44
|
+
module ActiveSupport
|
|
45
|
+
IsolatedExecutionState = RactorRailsShim::FallbackIES
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -293,7 +293,7 @@ module RactorRailsShim
|
|
|
293
293
|
# while the objects are still mutable) populates the ivars so the frozen,
|
|
294
294
|
# shared copies already hold the values and workers only read them.
|
|
295
295
|
if Ractor.main? && defined?(::Rails) && ::Rails.application
|
|
296
|
-
|
|
296
|
+
_swallow("warm journey routes") do
|
|
297
297
|
rset = ::Rails.application.routes
|
|
298
298
|
all = []
|
|
299
299
|
all.concat(rset.named_routes.send(:routes).values) rescue nil
|
|
@@ -310,8 +310,6 @@ module RactorRailsShim
|
|
|
310
310
|
p.required_names rescue nil
|
|
311
311
|
p.optional_names rescue nil
|
|
312
312
|
end
|
|
313
|
-
rescue
|
|
314
|
-
nil
|
|
315
313
|
end
|
|
316
314
|
end
|
|
317
315
|
# Capture the (shareable) RouteSet so workers can build URLs without
|
|
@@ -422,15 +420,15 @@ module RactorRailsShim
|
|
|
422
420
|
nrc.alias_method(:define_url_helper_without_shim, :define_url_helper)
|
|
423
421
|
end
|
|
424
422
|
nrc.define_method(:define_url_helper) do |mod, name, helper, url_strategy|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
423
|
+
# Detach the helper from the live route object before deep-freezing
|
|
424
|
+
# it for cross-Ractor sharing. The non-optimized UrlHelper#call only
|
|
425
|
+
# needs @options / @segment_keys / @route_name to build the options
|
|
426
|
+
# hash and then delegates to `t._routes.url_for(route_name, ...)`,
|
|
427
|
+
# which looks the route up in the (shareable) RouteSet by name. The
|
|
428
|
+
# @route reference would pull the whole route graph into the freeze,
|
|
429
|
+
# freezing objects that make_app_shareable! must still be able to
|
|
430
|
+
# mutate (e.g. Devise route constraints) -> FrozenError.
|
|
431
|
+
RactorRailsShim._swallow("freeze url helper") do
|
|
434
432
|
if helper.respond_to?(:instance_variable_get)
|
|
435
433
|
helper.instance_variable_set(:@route, nil) rescue nil
|
|
436
434
|
opts = helper.instance_variable_get(:@options)
|
|
@@ -439,8 +437,6 @@ module RactorRailsShim
|
|
|
439
437
|
helper.instance_variable_set(:@segment_keys, segs.dup.freeze) rescue nil
|
|
440
438
|
end
|
|
441
439
|
helper = Ractor.make_shareable(helper)
|
|
442
|
-
rescue
|
|
443
|
-
nil
|
|
444
440
|
end
|
|
445
441
|
RactorRailsShim::URL_HELPERS[name] = helper
|
|
446
442
|
strategy_const = url_strategy.equal?(::ActionDispatch::Routing::RouteSet::PATH) ?
|
|
@@ -705,7 +701,7 @@ module RactorRailsShim
|
|
|
705
701
|
if defined?(::ActionDispatch::Journey::GTG::Builder) &&
|
|
706
702
|
::ActionDispatch::Journey::GTG::Builder.const_defined?(:DUMMY_END_NODE)
|
|
707
703
|
node = ::ActionDispatch::Journey::GTG::Builder.const_get(:DUMMY_END_NODE)
|
|
708
|
-
Ractor.make_shareable(node)
|
|
704
|
+
_swallow("make journey dummy end node shareable") { Ractor.make_shareable(node) }
|
|
709
705
|
end
|
|
710
706
|
end
|
|
711
707
|
|
|
@@ -787,7 +783,7 @@ module RactorRailsShim
|
|
|
787
783
|
next unless c.is_a?(::Array) || c.is_a?(::Hash)
|
|
788
784
|
next if c.frozen?
|
|
789
785
|
c.freeze
|
|
790
|
-
::Ractor.make_shareable(c)
|
|
786
|
+
_swallow("make mime negotiation constant shareable") { ::Ractor.make_shareable(c) }
|
|
791
787
|
end
|
|
792
788
|
rescue => e
|
|
793
789
|
warn "[ractor-rails-shim] _freeze_mime_negotiation!: #{e.class}: #{e.message}"
|
|
@@ -244,7 +244,7 @@ module RactorRailsShim
|
|
|
244
244
|
(defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application) ? ::Rails.application.routes : nil,
|
|
245
245
|
nil
|
|
246
246
|
)
|
|
247
|
-
Ractor.make_shareable(fallback)
|
|
247
|
+
fallback = _swallow("make view context fallback shareable") { Ractor.make_shareable(fallback) }
|
|
248
248
|
self._view_context_fallback = fallback
|
|
249
249
|
end
|
|
250
250
|
|
|
@@ -199,12 +199,7 @@ module RactorRailsShim
|
|
|
199
199
|
end
|
|
200
200
|
snapshot.freeze
|
|
201
201
|
Ractor.make_shareable(snapshot)
|
|
202
|
-
|
|
203
|
-
begin
|
|
204
|
-
const_set(:AR_CONFIGURATIONS_SNAPSHOT, snapshot)
|
|
205
|
-
ensure
|
|
206
|
-
$VERBOSE = verbose
|
|
207
|
-
end
|
|
202
|
+
_reassign_shareable_const(:AR_CONFIGURATIONS_SNAPSHOT, snapshot)
|
|
208
203
|
rescue => e
|
|
209
204
|
# Best-effort; if we can't capture configs, workers won't be able
|
|
210
205
|
# to auto-init connections. They can call init_worker_ar_connections!
|
|
@@ -459,12 +454,7 @@ module RactorRailsShim
|
|
|
459
454
|
pk_map[n] = pk if pk
|
|
460
455
|
end
|
|
461
456
|
shareable = Ractor.make_shareable(pk_map)
|
|
462
|
-
|
|
463
|
-
begin
|
|
464
|
-
const_set(:AR_PRIMARY_KEYS_SHAREABLE, shareable)
|
|
465
|
-
ensure
|
|
466
|
-
$VERBOSE = verbose
|
|
467
|
-
end
|
|
457
|
+
_reassign_shareable_const(:AR_PRIMARY_KEYS_SHAREABLE, shareable)
|
|
468
458
|
rescue => e
|
|
469
459
|
# best-effort
|
|
470
460
|
end
|
|
@@ -916,12 +906,7 @@ module RactorRailsShim
|
|
|
916
906
|
cfg = ::ActiveRecord::Base.configurations
|
|
917
907
|
cfg = Ractor.make_shareable(cfg) if cfg
|
|
918
908
|
if cfg
|
|
919
|
-
|
|
920
|
-
begin
|
|
921
|
-
const_set(:AR_CONFIGURATIONS_SHAREABLE, cfg)
|
|
922
|
-
ensure
|
|
923
|
-
$VERBOSE = verbose
|
|
924
|
-
end
|
|
909
|
+
_reassign_shareable_const(:AR_CONFIGURATIONS_SHAREABLE, cfg)
|
|
925
910
|
end
|
|
926
911
|
rescue => e
|
|
927
912
|
# best-effort
|
|
@@ -977,14 +962,9 @@ module RactorRailsShim
|
|
|
977
962
|
handlers = ::ActiveRecord::DatabaseConfigurations.db_config_handlers
|
|
978
963
|
# Make each handler Proc shareable (freezes its binding). A shareable
|
|
979
964
|
# Proc is callable from any Ractor.
|
|
980
|
-
handlers.each { |h| Ractor.make_shareable(h)
|
|
965
|
+
handlers.each { |h| _swallow("make ar db config handler shareable") { Ractor.make_shareable(h) } }
|
|
981
966
|
shareable = Ractor.make_shareable(handlers.dup)
|
|
982
|
-
|
|
983
|
-
begin
|
|
984
|
-
const_set(:AR_DB_CONFIG_HANDLERS_SHAREABLE, shareable)
|
|
985
|
-
ensure
|
|
986
|
-
$VERBOSE = verbose
|
|
987
|
-
end
|
|
967
|
+
_reassign_shareable_const(:AR_DB_CONFIG_HANDLERS_SHAREABLE, shareable)
|
|
988
968
|
rescue => e
|
|
989
969
|
# best-effort
|
|
990
970
|
end
|
|
@@ -1031,14 +1011,9 @@ module RactorRailsShim
|
|
|
1031
1011
|
if Ractor.main?
|
|
1032
1012
|
begin
|
|
1033
1013
|
transformers = ::ActiveRecord.query_transformers
|
|
1034
|
-
transformers.each { |t| Ractor.make_shareable(t)
|
|
1014
|
+
transformers.each { |t| _swallow("make ar query transformer shareable") { Ractor.make_shareable(t) } }
|
|
1035
1015
|
shareable = Ractor.make_shareable(transformers.dup)
|
|
1036
|
-
|
|
1037
|
-
begin
|
|
1038
|
-
const_set(:AR_QUERY_TRANSFORMERS_SHAREABLE, shareable)
|
|
1039
|
-
ensure
|
|
1040
|
-
$VERBOSE = verbose
|
|
1041
|
-
end
|
|
1016
|
+
_reassign_shareable_const(:AR_QUERY_TRANSFORMERS_SHAREABLE, shareable)
|
|
1042
1017
|
rescue => e
|
|
1043
1018
|
# best-effort
|
|
1044
1019
|
end
|
|
@@ -1085,12 +1060,7 @@ module RactorRailsShim
|
|
|
1085
1060
|
begin
|
|
1086
1061
|
val = ::ActiveRecord.public_send(method_name)
|
|
1087
1062
|
shareable = Ractor.make_shareable(val.is_a?(::Array) ? val.dup : val)
|
|
1088
|
-
|
|
1089
|
-
begin
|
|
1090
|
-
const_set(const_name, shareable)
|
|
1091
|
-
ensure
|
|
1092
|
-
$VERBOSE = verbose
|
|
1093
|
-
end
|
|
1063
|
+
_reassign_shareable_const(const_name, shareable)
|
|
1094
1064
|
rescue => e
|
|
1095
1065
|
# best-effort
|
|
1096
1066
|
end
|