ractor-rails-shim 0.4.0 → 0.5.1

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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +645 -0
  3. data/LICENSE.txt +2 -2
  4. data/README.md +48 -6
  5. data/lib/ractor_rails_shim/callbacks/dependent_association_transport.rb +62 -5
  6. data/lib/ractor_rails_shim/callbacks/registry.rb +61 -7
  7. data/lib/ractor_rails_shim/callbacks/symbolic_transport.rb +271 -32
  8. data/lib/ractor_rails_shim/foundation/storage_strategy.rb +29 -6
  9. data/lib/ractor_rails_shim/loader.rb +14 -0
  10. data/lib/ractor_rails_shim/patches/action_cable.rb +165 -0
  11. data/lib/ractor_rails_shim/patches/action_controller.rb +51 -2
  12. data/lib/ractor_rails_shim/patches/action_dispatch.rb +11 -1
  13. data/lib/ractor_rails_shim/patches/action_view.rb +60 -2
  14. data/lib/ractor_rails_shim/patches/active_job.rb +113 -0
  15. data/lib/ractor_rails_shim/patches/active_model_attribute.rb +10 -0
  16. data/lib/ractor_rails_shim/patches/active_storage.rb +101 -2
  17. data/lib/ractor_rails_shim/patches/active_support.rb +173 -8
  18. data/lib/ractor_rails_shim/patches/activerecord.rb +960 -149
  19. data/lib/ractor_rails_shim/patches/callables.rb +60 -0
  20. data/lib/ractor_rails_shim/patches/cgi.rb +93 -0
  21. data/lib/ractor_rails_shim/patches/core.rb +125 -0
  22. data/lib/ractor_rails_shim/patches/dependencies_interlock.rb +62 -0
  23. data/lib/ractor_rails_shim/patches/devise.rb +2 -1
  24. data/lib/ractor_rails_shim/patches/execution_wrapper.rb +2 -1
  25. data/lib/ractor_rails_shim/patches/i18n.rb +24 -12
  26. data/lib/ractor_rails_shim/patches/kaminari.rb +2 -1
  27. data/lib/ractor_rails_shim/patches/live_streaming.rb +71 -0
  28. data/lib/ractor_rails_shim/patches/mail.rb +39 -5
  29. data/lib/ractor_rails_shim/patches/make_shareable.rb +22 -0
  30. data/lib/ractor_rails_shim/patches/mattr_accessor.rb +8 -4
  31. data/lib/ractor_rails_shim/patches/orm_adapter.rb +2 -1
  32. data/lib/ractor_rails_shim/patches/rack.rb +62 -5
  33. data/lib/ractor_rails_shim/patches/rails_module.rb +20 -7
  34. data/lib/ractor_rails_shim/patches/railtie_configuration.rb +132 -0
  35. data/lib/ractor_rails_shim/patches/secure_token.rb +131 -0
  36. data/lib/ractor_rails_shim/patches/solid_cache.rb +85 -0
  37. data/lib/ractor_rails_shim/patches/solid_queue.rb +117 -0
  38. data/lib/ractor_rails_shim/patches/thread_mattr.rb +112 -0
  39. data/lib/ractor_rails_shim/patches/time_zone_lookup.rb +94 -0
  40. data/lib/ractor_rails_shim/patches/turbo.rb +173 -0
  41. data/lib/ractor_rails_shim/patches/tzinfo_data_source.rb +107 -0
  42. data/lib/ractor_rails_shim/patches/zeitwerk_registry.rb +2 -1
  43. data/lib/ractor_rails_shim/roles/app_shareabilizer.rb +81 -0
  44. data/lib/ractor_rails_shim/roles/callback_capture.rb +221 -32
  45. data/lib/ractor_rails_shim/roles/fallback_builder.rb +113 -2
  46. data/lib/ractor_rails_shim/roles/install_strategy.rb +26 -0
  47. data/lib/ractor_rails_shim/roles/lifecycle.rb +63 -1
  48. data/lib/ractor_rails_shim/roles/per_ractor_cache.rb +288 -0
  49. data/lib/ractor_rails_shim/roles/shareability_traversal.rb +158 -29
  50. data/lib/ractor_rails_shim/roles/worker_app.rb +21 -0
  51. data/lib/ractor_rails_shim/version.rb +2 -2
  52. metadata +16 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c9a5a31eef9fc564a38af828fa80c55909878e502952358a129760441b36844b
4
- data.tar.gz: ed5bee3ef13dd02d95ab06e806cebc2a1248da438e6f82133654419a85485c28
3
+ metadata.gz: abdcca0b7262caf6686c80535a98635edcb4a6f02e373d1ab1cedde7d9ee8867
4
+ data.tar.gz: 021d39390668e696131e221d1a767a763ebc0765fa37ff3b5a4dbd082609b010
5
5
  SHA512:
6
- metadata.gz: c77e93d1676466dd59164e597f888837df387669a8579e0e3f5d2056fab47e4a9b7bcca7502993f932fdffc7e906b933a9724a71ab167bfb835680e083dba3d6
7
- data.tar.gz: 5822e8b0c5cb9c9ab204b9b052cb1501a1ecb4bf9a3d8737881095771835d8be5b7441f5d5d665ef1bb6781d422240a0135bc56079f02e5d08823bb8a8142c46
6
+ metadata.gz: 977596297e62a5743b33c7afcfdec8b3cb4f3be66aca0e477f7de4acc03f3213cfe04138bc99dbfb5bbe4982e8852cd47d145fce7e0536f60d9b5216cf7e4709
7
+ data.tar.gz: 6a46bc0e33bca9f70752effbfb6c5ba90e85b71cff16259b828787a1a9d539c1a93bbd32f491cbb681f7246527ffcb6065ba69cb2ae3ba7f92e9ef95c3c9359f
data/CHANGELOG.md CHANGED
@@ -7,6 +7,651 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.1] — 2026-10-25
11
+
12
+ ### Release housekeeping
13
+
14
+ - Version bump only. No functional changes.
15
+
16
+ ## [0.5.0] — 2026-10-24
17
+
18
+ ### Performance — read-path hot-loop optimization (measured)
19
+
20
+ Four changes targeting the per-request class-attribute/config read path,
21
+ benchmarked after each against the test app under kino `:ractor` (-w5 -t1,
22
+ Ruby 4.0.7, ab c=64):
23
+
24
+ - **Single-probe IES hit** (`StorageStrategy::Ractor` and all ~53 inline
25
+ eval'd readers): the non-nil IES hit no longer pays a second
26
+ `storage.key?` probe — the stored-nil disambiguation moved to the nil
27
+ path. Semantics identical (a stored nil is still a hit). Micro-bench
28
+ exact-key reader 2.51 → 3.52 Mops/s.
29
+ - **Worker IES prefill** (`WorkerApp#setup_once!`): each worker copies the
30
+ frozen shareable fallback table's non-nil slots into its own IES on
31
+ first request, so every later read of fallback-backed config is a
32
+ single first-tier hit. Per-ractor overrides are preserved (key? guard).
33
+ - **Boot-time GC.compact** (`prepare_for_ractors!`): one compaction in
34
+ main after the shareabilization passes, before workers spawn.
35
+ Explicitly NOT `GC.auto_compact` (which hangs kino :ractor under load).
36
+ Opt-outs: `RRAILS_SHIM_DISABLE_BOOT_COMPACT=1` / `RUBY_GC_DISABLE_COMPACTION=1`.
37
+ - New `bench/accessor_read_bench.rb` micro-benchmark (raw ivar floor vs
38
+ each lookup shape).
39
+ - **Compiled callback chains** (`SymbolicTransport` + `Registry`): chains
40
+ compile once per (class, kind) into frozen phase arrays — one ancestor
41
+ walk instead of three per replay, no per-replay `const_get` or
42
+ transport select. Micro-bench: 0.07 → 0.21 Mops/s (3×). E2E POST /posts
43
+ 3,000 → 3,445 rps on top of the other changes; `/up` 14,991 → 16,031.
44
+
45
+ End-to-end (req/s, baseline → full stack; two independent full-stack runs
46
+ agreed within noise): `/up` 13,986 → ~15,400 (+7-13%), `/posts_plain`
47
+ 4,245 → 4,743-4,900 (+12-15%), `POST /posts` 2,442 → 2,728-3,000
48
+ (+12-23%), `/posts` (Kaminari, DB-bound) 527 → 548-574 (+4-9%). Memory
49
+ flat (~270 MB cold). All 832 unit specs + strategy_proc + integration
50
+ spec green.
51
+
52
+ ### Added — multi-database (`connects_to` / `connected_to`) in worker Ractors
53
+
54
+ Test-app row 81 now runs a real second database, which exposed three gaps in
55
+ the worker connection setup:
56
+
57
+ - `init_worker_ar_connections!` established EVERY configured database on the
58
+ `:default` shard, so in a multi-database app the LAST config won — workers
59
+ queried the secondary database and raised `PG::UndefinedTable` for every
60
+ primary table. The config named `primary` (Rails' convention) now owns the
61
+ default shard; single-database apps fall back to the first entry.
62
+ - Each named database is registered for the writing AND the reading role;
63
+ `connected_to(role: :reading)` otherwise raised "calling `connected_to` is
64
+ not allowed when there is no connection defined for that role".
65
+ - New `_capture_ar_connection_classes!` (main, prepare time) records every
66
+ model that owns a pool via `connects_to` (Solid Cache/Queue/Cable records,
67
+ app multi-database models) together with the db config main resolved.
68
+ Workers re-establish those pools from the snapshot under the model's own
69
+ `connection_specification_name` — a lazily autoloaded class re-resolved
70
+ `connects_to` to the PRIMARY config (observed: `AuditEntry` queried the
71
+ primary database and its table was "missing").
72
+
73
+ Evidence: test app `/multi_db_probe` in a worker — secondary write + re-read,
74
+ a primary read in the same request, and
75
+ `SecondaryRecord.connected_to(role: :reading)`. Suite: 830 runs, 0 failures.
76
+
77
+ ### Added — turbo-rails worker support (test-app rows 105 + 121)
78
+
79
+ Adopting turbo-rails and exercising ActiveStorage direct uploads in worker
80
+ Ractors surfaced five framework-level walls, all fixed here:
81
+
82
+ - `patches/thread_mattr.rb` (new, install-time): `Module#thread_mattr_reader/
83
+ writer` generate methods that memoize their IES key in a class/module ivar
84
+ (`@__thread_mattr_#{sym} ||=`) — a write that raises IsolationError in
85
+ workers. turbo-rails' `Turbo.current_request_id` (around_action on EVERY
86
+ request) and `ActiveRecord::Base.suppressed_turbo_broadcasts` hit this, so
87
+ every worker request died with a 500. The patched macros emit the
88
+ deterministic key without the ivar memo (same per-class isolation).
89
+ - `patches/turbo.rb` (new, prepare-time): re-emits turbo's
90
+ `_render_with_renderer_turbo_stream` renderer as a string-eval'd `def`
91
+ (upstream `define_method`s a main-compiled Proc), and routes PROC layouts
92
+ (`Turbo::Frames::FrameRequest`'s conditional frame layout — re-emitted per
93
+ controller by the Layouts `inherited` hook) through a frozen
94
+ `PROC_LAYOUT_PROCS` registry read by a real `def _layout_from_proc`
95
+ (`instance_exec` preserves the define_method self-rebinding contract).
96
+ - `patches/secure_token.rb` (new, install-time): `has_secure_token` registers
97
+ a BLOCK callback — skipped in workers, so worker-created rows had NULL
98
+ tokens (`ActiveStorage::Blob#key` → direct uploads died with
99
+ PG::NotNullViolation). The macro wrapper appends an equivalent Symbol
100
+ callback (and re-applies it to models loaded before the patch, e.g.
101
+ ActiveStorage::Blob).
102
+ - `SymbolicTransport::DEFAULT_KINDS` now includes `:initialize` (Rails 8.1's
103
+ `generate_secure_token_on` default) — declared symbolic after_initialize
104
+ filters replay in workers.
105
+ - `patches/active_storage.rb`: prebuilds every configured service +
106
+ warms each service's memoized `url_helpers` in main (DiskService#url wrote
107
+ to a frozen instance), registers the blob `service_name` default
108
+ symbolically (seeded `ACTIVE_STORAGE_DEFAULT_SERVICE`), injects that default
109
+ in `create_before_direct_upload!` (the endpoint passes an explicit nil),
110
+ and re-registers `ActiveStorage::SetCurrent` as a Symbol before_action
111
+ (the upstream block never replays → "Missing host to link to!").
112
+ - `patches/active_controller.rb`-adjacent constants:
113
+ `ActionController::Parameters::EMPTY_ARRAY/EMPTY_HASH` registered shareable.
114
+
115
+ Evidence: test app rows 105 (turbo stream responses, `.turbo_stream.erb`
116
+ templates, model broadcast enqueued from a worker + performed by `bin/jobs`
117
+ with a solid_cable message) and 121 (rich_text_area; full direct-upload
118
+ endpoint cycle: POST → signed id/URL → disk PUT → blob redirect → service
119
+ URL bytes). Embed parsing remains the documented Nokogiri wall (row 50).
120
+
121
+ ### Fixed — worker-side `:memory_store` / `:null_store` (`Rails.cache` raised FrozenError on the first call)
122
+
123
+ `Rails.cache` is captured into `CLASS_ATTRIBUTES`. `FallbackBuilder` therefore
124
+ deep-froze the store at prepare time.
125
+
126
+ - `MemoryStore` refreshes LRU order with `@data.delete` / `@data[key] =` on
127
+ every read.
128
+ - `NullStore` memoizes `@local_cache_key` on the instance.
129
+
130
+ Both raised `FrozenError` on the first cache call in every Ractor. The failed
131
+ `make_shareable` also froze the live main-Ractor store, so main broke too.
132
+ Solid Cache and FileStore were unaffected: they hold no mutable hot-path state.
133
+
134
+ - `roles/per_ractor_cache.rb` (new): process-local stores are swapped for a
135
+ same-class shareable proxy. `klass.allocate` plus a singleton delegator keeps
136
+ `class` and `is_a?` exact. The proxy holds only a shareable spec (class name
137
+ plus option values that survive `Ractor.make_shareable`). Every store method
138
+ delegates to a `Ractor.current`-memoized instance, so each Ractor gets its own
139
+ `MemoryStore` — the same shape as forked processes. The main Ractor keeps the
140
+ original instance and its boot-warmed entries. `Rails.cache` and
141
+ `ActionController::Base.config.cache_store` are re-pointed at the proxy from
142
+ both `prepare_for_ractors!` and `make_app_shareable!`.
143
+ - `patches/active_support.rb`: `SerializerWithFallback::SERIALIZERS` added to
144
+ `SHAREABLE_CONSTANTS`. Worker-side store construction reads it through
145
+ `Store#initialize`. New `_install_cache_store_cattr_patch` re-routes
146
+ `Store.logger` / `raise_on_invalid_cache_expiration_time` through
147
+ `mattr_accessor` when `active_support/cache` loaded before the shim. Workers
148
+ cannot read `@@logger` at all.
149
+ - `patches/rails_module.rb`: the worker `Rails.cache` reader returns the proxy
150
+ constant first, then the shareable fallback.
151
+ - Redis/MemCache stores are not wrapped. Their connection config is not
152
+ reconstructible from `store.options`. A boot warning tells the operator to
153
+ use `:solid_cache_store` or `:file_store`.
154
+ - Spec: `spec/per_ractor_cache_spec.rb` (7 cases: classification, same-class
155
+ shareable proxy, main keeps the original, worker write/read/fetch, per-Ractor
156
+ isolation, NullStore, FileStore unwrapped).
157
+
158
+ ### Fixed — worker-side `serialize :arguments, coder: JSON` (Solid Queue enqueue from a worker, row 110)
159
+
160
+ Four coordinated fixes, all proven by the test app's `:ractor` suite plus an
161
+ end-to-end kino + `bin/jobs` dispatcher run (worker enqueues; the separate
162
+ dispatcher performs):
163
+
164
+ - `patches/solid_queue.rb` (new): prewarms `SolidQueue::Batch.migrated?`
165
+ (a lazily-memoized boolean class ivar on a frozen engine-model class —
166
+ the first worker enqueue died on the `||=` write) and re-registers the
167
+ execution models' `before_create -> { assume_attributes_from_job }`
168
+ BLOCK callback symbolically (`before_create :assume_attributes_from_job`
169
+ via a string-eval'd redefinition of the `JobAttributes` macro). Without
170
+ the latter, SolidQueue's `ScheduledExecution` INSERT died with
171
+ PG::NotNullViolation (null `queue_name`) because SymbolicTransport
172
+ fail-safe skips block filters. Each execution model's current assumable
173
+ list is re-applied (ReadyExecution none/default, ScheduledExecution
174
+ `:scheduled_at`, BlockedExecution `:concurrency_key`).
175
+ - `FallbackBuilder.try_make_shareable` no longer blanket-skips
176
+ `__class_attr__queue_adapter`: stateless DB-write adapters (SolidQueueAdapter)
177
+ freeze into the worker fallback so workers enqueue through the REAL
178
+ adapter; unshareable ones (AsyncAdapter's live scheduler) still fall back
179
+ to the declared default.
180
+ - `_share_ar_pending_attribute_modifications!` gains a binding-locals
181
+ retry pass + `SerializedTypeDecorator` (callables.rb): decoration Procs
182
+ whose captured locals are unfrozen value objects (e.g. the `serialize`
183
+ macro's `ActiveRecord::Coders::JSON` instance) now freeze those locals in
184
+ place and retry; serialization.rb-sourced decorations that can never be
185
+ shared (`coder ||=` reassignment taints the proc) are reconstructed as a
186
+ shareable callable that rebuilds `Type::Serialized` per apply. WITHOUT
187
+ this, workers rebuilt the `arguments` attribute as a plain Text type and
188
+ stored Ruby hash-inspect instead of JSON (the dispatcher then died with
189
+ JSON::ParserError). SCOPE GUARD: only value-object coders
190
+ (ActiveRecord::Coders::* instances) are reconstructed — coder CLASSES
191
+ (ActionText::Content, whose load path needs Nokogiri — the permanent
192
+ worker wall) keep the pre-fix no-op behavior, preserving row 120's
193
+ raw-HTML round-trip.
194
+ - `JsonCoderFreezer` (in patches/active_job.rb): freezes the
195
+ `JSONGemCoderEncoder::CODER` constant's value in main at prepare time so
196
+ the worker-side encode path (called by `ActiveRecord::Coders::JSON#dump`
197
+ through `Type::Serialized#serialize`) can read the constant legally.
198
+ - `prepare_for_ractors!` pipeline gains two steps (wired through
199
+ `AppShareabilizer`'s configure seam): `prewarm_solid_queue` and
200
+ `freeze_json_coder`.
201
+
202
+ ### Added — worker-side `around_action` / `:around` callback replay (wrap-style)
203
+
204
+ Symbolic method-name `:around` filters (`around_action :method`,
205
+ `around_save :method`, …) now replay in worker Ractors. `SymbolicTransport`
206
+ gains `around(context, kind, inner)` and `Registry#replay` builds a wrap
207
+ chain around the single yield. Nesting is pinned against the real
208
+ ActiveSupport compiled chain: first-declared `:around` filter outermost, the
209
+ after-half runs inside the innermost `yield`, a filter that never yields
210
+ suppresses the block and the after-half, and the chain evaluates to the
211
+ block's result (a yielding filter's own return value is discarded; a
212
+ non-yielding one evaluates to nil). Lambda/Proc around filters remain
213
+ fail-safe skipped (un-shareable Procs — same policy as every other Proc
214
+ filter). Specs: `spec/callbacks/around_transport_spec.rb`. This resolves the
215
+ test-app TODO #90 decision (`around_action`) in favor of shim support.
216
+
217
+ ## [0.4.2]
218
+
219
+ ### Fixed — worker time-zone creation dies on `TZInfo::DataSource`'s class variables
220
+
221
+ Surfaced by the same durations probe (the layer under the
222
+ `ActiveSupport::TimeZone.[]` fix): every `ActiveSupport::TimeZone`
223
+ creation resolves its TZInfo data through `TZInfo::DataSource.get`, which
224
+ memoizes into the class variables `@@instance` / `@@default_mutex`. Ruby
225
+ 4.0 forbids ANY class-variable access from non-main Ractors
226
+ (`Ractor::IsolationError: can not access class variables from non-main
227
+ Ractors`).
228
+
229
+ - `get` is now Ractor-aware: main keeps the upstream body verbatim
230
+ (class-variable memoize + mutex); workers get a per-Ractor DataSource
231
+ from IsolatedExecutionState built by the upstream private
232
+ `create_default_data_source` (same ruby-data/zoneinfo choice; per-
233
+ instance lookup caches make per-Ractor caching semantically
234
+ equivalent). The upstream `set` (manual source selection) stays
235
+ main-only.
236
+
237
+ ### Fixed — worker `Time.zone` resolution dies on `TimeZone.[]`'s Concurrent::Map class ivar
238
+
239
+ Surfaced by the test app's durations probe (row 131: `Time.zone` /
240
+ `Time.use_zone` from a worker).
241
+
242
+ - `Time.zone` (and `in_time_zone` / `use_zone`) resolves zones through
243
+ `ActiveSupport::TimeZone.[]`, whose String branch memoizes into the class
244
+ ivar `@lazy_zones_map` — a `Concurrent::Map` set at load time. Both
245
+ halves are forbidden in workers: reading the unshareable Concurrent::Map
246
+ class ivar raises `Ractor::IsolationError` (can not get unshareable
247
+ values from instance variables of classes/modules), and a worker-side
248
+ memoize would try to set it.
249
+ - The patched `[]` keeps the upstream body verbatim on main; workers use a
250
+ per-Ractor Hash cache from IsolatedExecutionState. Zone instances are
251
+ plain data and TZInfo lookups are worker-safe, so worker-local caches are
252
+ semantically equivalent. The numeric-offset branch (`all.find`) stays
253
+ main-only (`all` also reads class ivars).
254
+
255
+ ### Fixed — worker-side streaming (`ActionController::Live`) hangs / dies
256
+
257
+ Surfaced by the test app's blob proxy dispatch (row 127:
258
+ `GET /rails/active_storage/blobs/proxy/:signed_id/:filename` in a worker).
259
+
260
+ - The Active Storage blob proxy controller includes `ActionController::Live`,
261
+ which processes the action on a pool thread: a lazy class-method reader
262
+ sets a class ivar (`@live_thread_pool_executor ||=
263
+ Concurrent::CachedThreadPool.new` — Ruby 4.0 forbids class-ivar writes
264
+ from non-main Ractors, `Ractor::IsolationError`), and `Live#process` then
265
+ blocks on `await_commit` until the spawned thread commits. Even with a
266
+ worker-local pool, the thread hop deadlocks in practice: any exception
267
+ before `commit!` kills the pooled thread (`abort_on_exception = true`)
268
+ and the request hangs forever.
269
+ - The patch runs the streaming action INLINE in worker Ractors:
270
+ `new_controller_thread` yields on the request thread (main keeps the
271
+ upstream pool-thread behavior verbatim). The bytes still stream through
272
+ the Rack response body; only true streaming concurrency (early header
273
+ flush) is lost worker-side, which is documented in the row note.
274
+
275
+ ### Fixed — worker `Dependencies.interlock` read dies on the unshareable class ivar
276
+
277
+ Surfaced by the same blob proxy dispatch (the next frame after the
278
+ streaming fix): `ActionController::Live#process` runs the action inside
279
+ `ActiveSupport::Dependencies.interlock.running { ... }`, and upstream sets
280
+ the `interlock` class ivar at load time to an Interlock wrapping an
281
+ unshareable `Concurrency::ShareLock` — reading it from a worker is a
282
+ `Ractor::IsolationError` (can not get unshareable values from instance
283
+ variables of classes/modules). Regular worker requests don't hit it: the
284
+ shim's production stack drops the `ActionDispatch::Executor` middleware,
285
+ the only other interlock consumer.
286
+
287
+ - The reader is replaced with a Ractor-aware method: main returns the
288
+ upstream class ivar (100% upstream semantics); workers get a per-Ractor
289
+ Interlock from IsolatedExecutionState. Workers never reload (the graph
290
+ is frozen), so the per-Ractor lock never contends. The upstream writer
291
+ (`interlock=`) is untouched.
292
+
293
+ ### Fixed — `config_for` / method_missing-backed config reads die in workers (`@@options` class variable)
294
+
295
+ Surfaced by the test app's `GET /features/config_probe` (rows 115/134/138:
296
+ read the production SMTP shape, `config.x`, and `config_for(:audit)` from a
297
+ worker).
298
+
299
+ - **`Rails::Railtie::Configuration` stores its method_missing option hash in
300
+ the class variable `@@options`**, and Ruby 4.0 forbids ANY class-variable
301
+ access from a non-main Ractor (`Ractor::IsolationError: can not access
302
+ class variables from non-main Ractors`) — so any worker-side read of an
303
+ option that is not an explicit method (e.g. `config.x`) died.
304
+ - The patch is installed with `class_eval` (NOT prepend): Ruby forbids
305
+ class-variable access from a prepended module ("class variable @@options
306
+ ... is overtaken by <module>"), and even a non-accessing worker branch
307
+ would be compiled into the module. Methods defined via class_eval on the
308
+ class itself legally own the cvar, so the main branch delegates to the
309
+ aliased originals — 100% upstream semantics on main.
310
+ - Workers read through a **frozen snapshot** of the main-side `@@options`,
311
+ taken by the AppShareabilizer freeze pipeline (after Proc/Map/Lock
312
+ replacement, before the final make_shareable) and stored as the frozen
313
+ constant `RRS_FROZEN_OPTIONS` on the class (constants are shareable; the
314
+ class variable is not). Per-value `make_shareable` with rescue:
315
+ non-shareable leftovers (lock objects) are skipped. A per-Ractor IES
316
+ overlay (keyed by the config object's identity) covers worker-local
317
+ writes. Unknown names raise NoMethodError in workers instead of falling
318
+ through to the original (whose @@options access is forbidden).
319
+
320
+ ### Fixed — intermittent worker `NoMethodError: undefined method 'owned?'` on NoOpLock
321
+
322
+ Observed ~1-in-7 kino runs on `GET /`: a worker hit
323
+ `Concurrent::Synchronization::MutexLockableObject#synchronize`, which (since
324
+ concurrent-ruby 1.3.8) checks `@__Lock__.owned?` before locking — and the
325
+ shim's `NoOpLock` (the shareable stand-in swapped for `Mutex`/`Monitor`
326
+ ivars during the graph freeze) didn't define `owned?`. Added
327
+ `def owned?; true; end`: callers take the direct-yield path, which is the
328
+ correct semantics for a never-contended post-boot lock.
329
+
330
+ ### Fixed — `normalizes` breaks every User-touching worker route (DelegateClass types poison `instance_method` cross-Ractor)
331
+
332
+ Surfaced by the test app's `GET /features/normalizes_probe` (TODO P2 #3,
333
+ row 74: `User normalizes :email, with: ->(v) { v.strip.downcase }`).
334
+
335
+ - **`SerializeCastValue::ClassMethods#serialize_cast_value_compatible?` is not
336
+ DelegateClass-safe in workers** (`patches/activerecord.rb`): adding
337
+ `normalizes` to User wraps the attribute type in
338
+ `ActiveModel::Attributes::Normalization::NormalizedValueType`, a
339
+ `DelegateClass` subclass. DelegateClass defines its delegator methods via
340
+ `define_method` with **Proc bodies created in the defining Ractor**, and
341
+ Ruby 4.0 refuses `instance_method` / `instance_methods` on such classes
342
+ from another Ractor (`RuntimeError: defined with an un-shareable Proc in
343
+ a different Ractor`) — even for plain `def` methods on the same class.
344
+ The shim's IES-backed patch of `serialize_cast_value_compatible?` calls
345
+ `instance_method(...)` for every type construction, so ANY worker touching
346
+ User attributes boomed (Devise before_actions, mail probes, attach probes,
347
+ the root page, POST /posts...). The patch now rescues the cross-Ractor
348
+ error and recomputes with table snapshots only —
349
+ `method_defined?(sym, false)` per ancestor (verified safe cross-Ractor) —
350
+ reproducing the same first-owner comparison as upstream without Method
351
+ objects. Per-class IES key unchanged.
352
+
353
+ ### Fixed — worker-side ActiveRecord enums (row 65)
354
+
355
+ Four worker-hostile method families generated by AR enums:
356
+
357
+ - predicates/bangs + class-level values reader + per-value scopes are
358
+ define_method'd with Procs compiled inside `_enum_methods_module` (a
359
+ Module the proc-replacement traversal cannot reach — it walks ivars,
360
+ not method tables) or singleton define_method Procs capturing
361
+ name/value locals that workers replay with a lost binding (NameError).
362
+ - Fix: the enum methods are re-defined as string-eval'd REAL defs (the
363
+ predicate compares `#{attr}_for_database`, bangs write via `update!`,
364
+ scopes build `all.where`/`where.not`, the pluralized reader returns a
365
+ const-backed frozen HWIA). Safe because Rails' `detect_enum_conflict!`
366
+ guarantees no user method occupies these names.
367
+
368
+ ### Fixed — worker-side validations silently passed everything (rows 1+98)
369
+
370
+ Worker-side `valid?` returned TRUE with zero errors — invalid records
371
+ could save in workers (data corruption). Root causes, all fixed:
372
+
373
+ - The set_callback interceptor only recorded Symbol filters;
374
+ Validator-object filters (ActiveModel::Validator instances registered
375
+ by `validates` / `validates_with`) were never captured. The shim now
376
+ builds a shareable descriptor `{validator-class-name, attributes,
377
+ options, declaring-class}` and the SymbolicTransport rebuilds a fresh
378
+ validator in the worker and calls `.validate(record)` (return value
379
+ ignored — validators signal via errors, never halt).
380
+ - Phase parsing: Rails passes the phase as filter_list[0] only when
381
+ explicit; no-explicit-phase declarations (`validate :sym`) were
382
+ silently missed (parsed per normalize_callback_params).
383
+ - DEFAULT_KINDS was missing `:validate` (the ActiveModel validations
384
+ chain), and the interceptor predicate now includes ActiveRecord::Base
385
+ itself so Base-level declarations replay.
386
+
387
+ ### Fixed — DelegateClass "special" methods, ActionView date selectors, CurrentAttributes keys in workers
388
+
389
+ - Ruby's delegate.rb defines every DelegateClass(X)'s "special" methods
390
+ (incl. `<<`) via `define_method(Delegator.delegating_block(m))` —
391
+ main-compiled lambdas that workers cannot call. They are detected by
392
+ shared source_location with a delegating-block marker and redefined as
393
+ string-eval'd defs. Fixes multipart parsing in workers
394
+ (Collector#on_mime_body calls Tempfile#<<).
395
+ - ActionView's DateTimeSelector#sec/min/hour/day/month/year (upstream
396
+ `define_method(&block)`) redefined as real defs — `date_select`
397
+ rendered NoMethodError in workers; plus a SelectType patch for select
398
+ helpers.
399
+ - CurrentAttributes' key-generated methods raised on frozen module reads
400
+ in workers — `Current.request_id` now round-trips.
401
+
402
+ ### Fixed — worker-side Solid Cache (:solid_cache_store) round-trip; connects_to pool fallback; broken `reset_table_name` super
403
+
404
+ Surfaced by the test app's `GET /solid_cache_probe` (TODO #27: verify rows
405
+ 45–47 against the REAL production store instead of the kino's historical
406
+ `:null_store` no-op path).
407
+
408
+ - **Broken `super` in the `reset_table_name` patch** (`patches/activerecord.rb`):
409
+ the shim's ModelSchema patch was installed via
410
+ `ModelSchema::ClassMethods.module_eval`, which REPLACES the original
411
+ `reset_table_name` in place — so the override's `super` skipped past the
412
+ module and found nothing. Every lazy main-side `reset_table_name` after
413
+ patch install raised `NoMethodError: super: no superclass method
414
+ 'reset_table_name'` (silently swallowed by the warm passes' rescues). The
415
+ visible victims were HABTM join models, whose table names resolve lazily
416
+ through the builder's `table_name_resolver` AFTER patch install: their
417
+ `@table_name` never materialized and the resolver lambda survived the
418
+ graph freeze unfrozen, so worker-side HABTM writes died with
419
+ `IsolationError ... (@table_name_resolver from Post::HABTM_Tags)` —
420
+ with a heap-layout-sensitive failure rate that made the kino flaky across
421
+ shim changes. The override now captures the original via `alias_method`
422
+ and calls it explicitly; `ShareabilityTraversal.harden_ar_model_ivars!`
423
+ (called right before the graph freeze) additionally materializes join-model
424
+ `@table_name` and freezes the resolver lambda, with loud warnings instead
425
+ of silent skips.
426
+
427
+ - **connects_to pool fallback** (`patches/activerecord.rb`):
428
+ `init_worker_ar_connections!` + the patched `connection_pool` /
429
+ `retrieve_connection` / `connected?`. connects_to-style models (Solid
430
+ Cache / Solid Queue / Solid Cable) own their pool in the MAIN handler under
431
+ their CLASS name (`connects_to` sets `@connection_specification_name`) and
432
+ carry their own `default_shard` class_attribute (e.g. SolidCache::Record
433
+ gets `default_shard == :primary` from config/cache.yml `database: primary`),
434
+ while the worker handler only seeded Base-level (role, :default) pools —
435
+ workers querying such a model died with
436
+ `ConnectionNotDefined: No database connection defined for
437
+ SolidCache::Record with 'primary' shard`. Two fixes: (1) the worker seed
438
+ also registers each named env database config under its own name as a
439
+ shard key (the standard `connects_to database: <name>` convention), and
440
+ (2) the patched readers fall back to the Base-level pool for the same
441
+ role/shard when the class-owned pool is not present in the worker.
442
+ - **Per-Ractor `SolidCache::Entry.select_sql` memo**
443
+ (`patches/solid_cache.rb`, new): the read path memoizes per-keys-count SQL
444
+ in the class ivar `@select_sql` — a worker cannot write class ivars
445
+ (IsolationError). The singleton method now routes the memo through
446
+ Ractor.current in workers; main keeps the original lazy-memo semantics.
447
+
448
+ ### Fixed — worker-Ractor mail hooks + ActiveJob `set(wait:)` enqueue; deterministic HABTM shareability
449
+
450
+ Surfaced by the test app's worker-Ractor audit of mail hooks (matrix rows
451
+ 113/116) and ActiveJob enqueue options (rows 107/108). Verified by
452
+ `GET /stats/mail_hooks_probe` (interceptor + mailer-callback headers stamped on
453
+ worker-delivered mail) and `GET /features/job_resilience_probe`
454
+ (`set(queue:, wait:)` enqueue + rescue-handler config readable from a worker).
455
+
456
+ - **Mail hook registry snapshot** (`patches/mail.rb` +
457
+ `AppShareabilizer.make_shareable!`): the mail gem's
458
+ `inform_interceptors` / `inform_observers` read raw class variables, and the
459
+ shim's worker branch historically returned a per-worker EMPTY list —
460
+ interceptors/observers registered in main during boot silently never ran in
461
+ workers. The registries are now snapshotted as a frozen list of hook NAMES
462
+ (`RactorRailsShim::MAIL_INTERCEPTORS` / `MAIL_OBSERVERS`) AFTER the app-graph
463
+ freeze; workers resolve the constants and run the same hook objects main
464
+ registered. Non-module hooks (stateful instances) are skipped with a warning.
465
+ Placement note: the snapshot deliberately runs post-freeze — earlier
466
+ placements perturbed the heap-layout-sensitive make_shareable walk.
467
+ - **Deterministic HABTM join-model freeze**
468
+ (`ShareabilityTraversal.harden_ar_model_ivars!`, called right before the
469
+ graph freeze): HABTM join models resolve their table name through a lazy
470
+ resolver lambda (`@table_name_resolver = -> { table_name }`). Whether the
471
+ make_shareable walk reached it was heap-layout sensitive — some
472
+ configurations left it unfrozen and every worker-side HABTM write
473
+ (`post.tags << tag`, dependent destroys) died with
474
+ `IsolationError ... (@table_name_resolver from Post::HABTM_Tags)`. The
475
+ explicit freeze (run after Proc/Map replacement cleaned the graph, so it
476
+ succeeds) makes worker-side HABTM deterministic.
477
+ - **Per-Ractor Concurrent global executors**
478
+ (`_install_concurrent_global_timer_set_patch`): a worker-side
479
+ `set(wait:)` enqueue schedules via `Concurrent::ScheduledTask`, whose default
480
+ timer/notification path reads the unshareable `GLOBAL_TIMER_SET` /
481
+ `GLOBAL_IO_EXECUTOR` / `GLOBAL_FAST_EXECUTOR` constants —
482
+ `IsolationError: can not access non-shareable objects in constant
483
+ Concurrent::GLOBAL_TIMER_SET by non-main ractor`. The readers now give each
484
+ worker its own TimerSet / ThreadPoolExecutor through IES; main keeps the
485
+ global instances.
486
+
487
+ ### Fixed — Rails 8.1 `signed_id` / `find_signed` / `token_for` from a worker Ractor
488
+
489
+ `User.find_signed(params[:token])` from a worker Ractor died with
490
+ `NoMethodError: undefined method '[]' for ... NoOpProc`: the signed-id salt
491
+ ("active_record/signed_id") builds its verifier codec LAZILY on first use, and
492
+ by the time a worker asks, the coordinator's rotation Proc / secret generator
493
+ have become NoOpProcs (Proc replacement) on a frozen graph. Verified by the
494
+ test app's `GET /features/signed_id_probe` → `generate` / `find_signed` round
495
+ trip from a kino `:ractor` worker.
496
+
497
+ - **Codec pre-warm** (`ShareabilityTraversal.prewarm_message_verifier_codecs!`,
498
+ called from `prepare_for_ractors!` right after the ActiveStorage prefix
499
+ seed): main builds the lazily-created codecs ("active_record/signed_id",
500
+ "active_record/token_for") up front so workers hit the frozen `@codecs`
501
+ cache. The salt list is deliberately method-local — load-time allocations in
502
+ the shim used to perturb the order-sensitive attribute-method generation
503
+ downstream.
504
+ - **`SerializerWithFallback.serializer_for` NameError fix**
505
+ (`patches/active_support.rb`): the patch is string-eval'd on the singleton
506
+ class, where a bare `SERIALIZERS` reference raises NameError (cref ancestors
507
+ exclude the module). Now fully qualified:
508
+ `::ActiveSupport::Messages::SerializerWithFallback::SERIALIZERS.fetch(format)`.
509
+ - **stdlib `JSON` default-option ivars** (`patches/make_shareable.rb`): the
510
+ legacy signed-id rotation passes `serializer: JSON` (stdlib), and JSON
511
+ memoizes `@dump_default_options` / `@load_default_options` / etc. as class
512
+ ivars — workers could not even read them (`IsolationError ... from JSON`).
513
+ Registered in `SHAREABLE_CLASS_IVARS` so they deep-freeze at prepare time.
514
+ - **`MessageVerifier#digest_length_in_hex` memo patch**
515
+ (`patches/active_support.rb`): the lazily memoized `@digest_length_in_hex
516
+ ||=` writes to the deep-frozen verifier at first generate/verify —
517
+ `FrozenError: can't modify frozen ActiveSupport::MessageVerifier`. Replaced
518
+ with a direct `OpenSSL::Digest` computation (allocation is negligible next
519
+ to the HMAC itself).
520
+ - **`ActiveRecord` attribute-state robustness** (`patches/active_model_attribute.rb`):
521
+ in MAIN, `attribute_types` / `_default_attributes` no longer memoize through
522
+ `RactorRailsShim.storage` — that memo had no invalidation path in main
523
+ (`reset_column_information` cannot see it), so an empty type-set built
524
+ before a model's schema settled poisoned `attribute_names` permanently.
525
+ Main now uses the upstream class-ivar memo (which every reset path clears);
526
+ workers keep the per-Ractor IES path.
527
+ - **Prefix-seed ordering fix** (`roles/lifecycle.rb`): `_seed_active_storage_prefix!`
528
+ now runs BEFORE `install_framework_patches`. The AR model-classes patch runs
529
+ `load_schema` on every concrete model at install time; with the prefix
530
+ unseeded, ActiveStorage models computed table names like `"attachments"` and
531
+ the failing attempts poisoned memoized attribute state —
532
+ `generate_ar_attribute_methods!` then generated modules missing column
533
+ writers, and workers lost `ActiveStorage::Attachment#name=` (DelegationError
534
+ on `Attachment.new`). Seeding first closes that window.
535
+ - Dispatch list updated in `spec/framework_patch_dispatch_spec.rb` for the new
536
+ `_install_message_verifier_digest_memo_patch`.
537
+
538
+ ### Fixed — shim TODO #5: ActiveJob `perform_later` / `deliver_later` from a worker Ractor
539
+
540
+ The last code-level ❌ in `FEATURES.md`. `WelcomeJob.perform_later(user)` from
541
+ a worker Ractor crashed with two different walls; both fixed. Regression specs
542
+ in `spec/cgi_escape_patch_spec.rb`; verified end-to-end by the test app's
543
+ `GET /job_enqueue_probe` → 200 `{enqueued: true, job_class: "WelcomeJob"}`
544
+ from real kino `:ractor` workers.
545
+
546
+ - **`GlobalID.app` captured via `SHAREABLE_CLASS_IVARS`** (`["GlobalID",
547
+ :@app]`, `patches/make_shareable.rb`). The railtie sets it to an *unfrozen*
548
+ String, and workers cannot even READ an unfrozen class ivar —
549
+ `GlobalID.create` (any ActiveJob argument serialization) died with
550
+ `IsolationError: can not get unshareable values from instance variables
551
+ ... (@app from GlobalID)` before GlobalID's own "An app is required" check
552
+ even ran. Deep-frozen at prepare time like the other captured ivars.
553
+ - **New `patches/cgi.rb`** — `CGI::Escape`/`CGI::EscapeExt` default their
554
+ `unescape` / `unescapeURIComponent` encoding argument to the
555
+ `@@accept_charset` CLASS VARIABLE (the C ext impl from `cgi/escape.so`
556
+ reads it internally too). Ruby forbids reading class variables from
557
+ non-main Ractors outright — even shareable ones. globalid's URI::GID
558
+ parsing calls `CGI.unescape`, putting this on the enqueue path. Both
559
+ methods are re-defined (string eval, shareable) defaulting to a frozen
560
+ snapshot constant; wired into the Ractor install strategy before eager
561
+ load. Requires no behavior change: explicit-encoding calls keep working,
562
+ and the class variable storage is untouched for main-Ractor callers.
563
+ - **`queue_adapter` needed no shim change**: ActiveJob's reader lazily
564
+ instantiates a per-worker adapter (`self.queue_adapter = :async if
565
+ _queue_adapter.nil?`) through the class_attribute IES writer, which
566
+ already works in workers.
567
+
568
+ ### Fixed — worker-Ractor cache access (low-level Rails.cache + fragment caching)
569
+
570
+ Exercising the caching audit in the test app surfaced three isolation walls
571
+ that made ANY cache call (`Rails.cache.fetch`, view fragment caching) die in a
572
+ worker Ractor. All fixed; regression specs in
573
+ `spec/cache_option_aliases_shareable_spec.rb`.
574
+
575
+ - **`ActiveSupport::Cache::OPTION_ALIASES` registered in
576
+ `SHAREABLE_CONSTANTS`** (`patches/active_support.rb`). Rails freezes the
577
+ Hash shallowly — its Array values stay unfrozen — so workers could not even
578
+ READ the constant (`IsolationError` in `Store#normalize_options` →
579
+ `merged_options`), which broke every `fetch`/`read`/`write` with an options
580
+ hash. Deep-frozen at boot like the other read-only alias maps.
581
+ - **`ActiveSupport::Cache::Coder` constants registered** (`PACKED_TYPE_
582
+ TEMPLATE`, `PACKED_EXPIRES_AT_TEMPLATE`, `PACKED_VERSION_LENGTH_TEMPLATE`,
583
+ `STRING_DESERIALIZERS`, `STRING_ENCODINGS`) — read during cache-entry
584
+ deserialization (`Coder#load`); the next wall behind OPTION_ALIASES.
585
+ - **Cache serializer availability probes warmed in main**
586
+ (`_warm_cache_serializer_fallbacks!`, new step in the `AppShareabilizer`
587
+ pipeline between `_warm_attribute_method_patterns!` and
588
+ `_freeze_declared_callbacks!`). The `SerializerWithFallback` fallbacks
589
+ (Rails 8.1: `module ... extend self`) memoize `available?` as an ivar on
590
+ the module object; a worker making the first probe crashed on the ivar
591
+ WRITE (`can not set instance variables of classes/modules by non-main
592
+ Ractors`) during `Coder#load` → `dumped?`. Warming the probes before the
593
+ freeze leaves `defined?(@available)` true with a shareable boolean.
594
+ - **`ActiveSupport::NumberHelper` converter constants registered**
595
+ (`NumberConverter::DEFAULTS`, `NumberToDelimitedConverter::
596
+ DEFAULT_DELIMITER_REGEX`, `NumberToHumanConverter::DECIMAL_UNITS` /
597
+ `INVERTED_DECIMAL_UNITS`, `NumberToHumanSizeConverter::STORAGE_UNITS`) —
598
+ read on every `number_with_delimiter` / `number_to_currency` / human-size
599
+ call; `DEFAULTS` was entirely unfrozen. Needed once views render number
600
+ helpers from worker Ractors.
601
+
602
+ Verified end-to-end: the test app's fragment / russian-doll / low-level
603
+ caching and number-helper views now serve from real kino `:ractor` workers
604
+ (`root_load_test.rb` passes; full suite 111 runs / 0 failures / 0 skips).
605
+
606
+ Verified end-to-end: the test app's kino (`:ractor` kino, production
607
+ semantics) exercises streaming blob proxy serving, worker-side time
608
+ zones, the error reporter, config reads, the public Active Storage
609
+ service, the analyzer chain, and Solid Cache writes with expiry; the
610
+ full test suite is 180 runs / 787 assertions / 0 failures and the shim
611
+ spec suite is 804 runs / 1699 assertions / 0 failures / 4 skips.
612
+
613
+ ## [0.4.1]
614
+
615
+ ### Fixed — community-reported crash trio (GitHub issues #1, #2, #3)
616
+
617
+ All three were triggered by the same docs gap: thread-mode users calling
618
+ `prepare_for_ractors!` (Ractor-mode-only) because the README presented it
619
+ unconditionally. Two real code defects were also exposed and fixed.
620
+
621
+ - **`prepare_for_ractors!` now skips itself in thread mode** with a loud
622
+ warning (`roles/lifecycle.rb`). It is Ractor-mode-only: it dispatches the
623
+ full per-Ractor patch set and deep-freezes live Rails objects, which
624
+ breaks thread servers (Puma/Falcon/Thin/Webrick). Running it in thread
625
+ mode froze the main Ractor's live `DatabaseConfigurations::HashConfig`
626
+ objects → `FrozenError` in `Migration::CheckPending` on every dev HTTP
627
+ request. (Fixes #2; root cause of the trigger conditions in #1 and #3.)
628
+
629
+ - **`SHAREABLE_AUTOSAVE_REFLECTIONS` registry swap made atomic**
630
+ (`patches/activerecord.rb`). `_rrs_store_autosave_reflection` removed the
631
+ constant before calling `Ractor.make_shareable`, so an unshareable
632
+ reflection (e.g. one capturing a Proc) left the constant permanently
633
+ missing and every later association declaration raised
634
+ `NameError: uninitialized constant SHAREABLE_AUTOSAVE_REFLECTIONS` on
635
+ boot for any app with ActiveStorage attachments. Now compute-then-swap
636
+ (a failure leaves the previous registry intact) plus self-healing (a
637
+ missing constant is re-seeded on the next store). (Fixes #1.)
638
+
639
+ - **`action_methods` nil cache read fixed** in both patched copies
640
+ (`AbstractController::Base` and `AbstractController::UrlFor::ClassMethods`,
641
+ `patches/action_controller.rb`). `instance_variable_defined?` is true for
642
+ defined-but-nil ivars, and `clear_action_methods!` (Rails' invalidation
643
+ hook, hit while a mailer/controller class body is evaluated) sets
644
+ `@action_methods = nil` — so the next call read nil, cached nil, and
645
+ returned it, raising `NoMethodError: undefined method 'include?' for nil`
646
+ in `ActionMailer::Base#respond_to_missing?` (crashed `bin/rails
647
+ db:migrate` during a kamal release phase). (Fixes #3.)
648
+
649
+ ### Changed — docs
650
+ - README Install section split into two explicit tracks (Ractor mode with
651
+ kino vs Thread mode with Puma/Falcon/Thin/Webrick), with warnings that
652
+ `prepare_for_ractors!` / `make_app_shareable!` must never be called in
653
+ thread mode.
654
+
10
655
  ## [0.4.0]
11
656
 
12
657
  ### Added — Rails feature support for worker Ractors