ractor-rails-shim 0.2.2 → 0.2.5

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 49ca69607b89c8817a961657ddccd6a3c1a3e87084ef0ec34090b91bce396b22
4
- data.tar.gz: cc35444b2078ea06545412a8c6e0d44dd03d2e07c3c2fd99f7a9e930a6fb897e
3
+ metadata.gz: 267a3734452aa4069b68599b193d8bebd3fa6e386836ec9ec4238d9667f6b37a
4
+ data.tar.gz: 748ee127a59a4e7cc0b90b16ff7339edb2dfd6cde3b15df01c1ff29d910d05c3
5
5
  SHA512:
6
- metadata.gz: 88badfc12887a028b39500559256df576e7317e2dbb9f9e4ee170f116279724a07aef6d3ab4a2bae848c12e04bbc56eb6ffc5c487a54f400aa60ff8b8ef3b0fe
7
- data.tar.gz: 95751eb76405f35d3fa1b2f53c89f2d459af19e7503156597d282f23a3c7da2ac71d6836dc54d80c7991b1e623d66c30fd72e67001530d4636fc7846177ed89d
6
+ metadata.gz: fdd580e1b1c24d324dfc9067a323dff580acd612ac8cfc2551424d4d359d5fc93d55c7ae7a9825efecad3103ef2c59b1b481a34d9270471f23fd19747dc9a52c
7
+ data.tar.gz: '0523817853ccdfc9444c0815fcb943b7cb3f745519b5baf6683e269e60c81889f97500e7e9e34da7bd3da7e74a3fd3033c5bbd25c4de6296337735ed6fd8301c'
data/CHANGELOG.md CHANGED
@@ -7,9 +7,73 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
- ### Changed
11
- - Improved the published gem summary and description (gemspec). Metadata-only
12
- release — no code changes.
10
+ ## [0.2.5]
11
+
12
+ ### Fixed
13
+ - **Nil-sentinel storage bug in IES-routed accessors.** The shim's
14
+ `mattr_accessor` / `class_attribute` / Rails-module / AR / Rack / Devise /
15
+ Kaminari / I18n / Inflector / ActionView / ActionController /
16
+ ActionDispatch / Zeitwerk / ExecutionWrapper readers used
17
+ `return v unless v.nil?` to detect "no per-Ractor override has been set."
18
+ That made `Foo.x = nil` (or `= false`) indistinguishable from "never set,"
19
+ so the reader silently fell through to the default/fallback instead of the
20
+ user's explicit `nil`/`false` — a divergence from threaded Rails, where
21
+ class variables distinguish "undefined" (`class_variable_defined?` is
22
+ false) from "set to nil." Replaced the nil-sentinel with `<storage>.key?`
23
+ at every IES / `CLASS_ATTR_VALUES` / `SHAREABLE_FALLBACK` reader so any
24
+ explicit assignment — including `nil` and `false` — wins over the
25
+ fallback. The single `Ractor.current[:active_record_connection_handler]`
26
+ reader keeps the nil-sentinel (with an explanatory comment) because
27
+ `Ractor#[]` has no `key?` method and `connection_handler=` is never called
28
+ with nil in practice. 58 sites transformed across 13 patch files.
29
+ Regression specs in `spec/sentinel_spec.rb` cover the
30
+ `mattr_accessor :flag, default: true; Flag = nil; Flag # => nil` and
31
+ `class_attribute :setting, default: :on; Setting = nil; Setting # => nil`
32
+ cases.
33
+
34
+ - **`Ractor::IsolationError` in worker schema reload.** When a worker Ractor
35
+ hit a cold schema (e.g. first request after boot, or after
36
+ `ActiveRecord::Base.connection_handler.clear_all_connections!`),
37
+ `ModelSchema::ClassMethods#reload_schema_from_cache` and
38
+ `Timestamp::ClassMethods#reload_schema_from_cache` wrote to class instance
39
+ variables (`@columns`, `@columns_hash`, …) on the shared model classes,
40
+ raising `Ractor::IsolationError: can not set instance variables of
41
+ classes/modules by non-main Ractors`. Patched both methods (plus
42
+ `AttributeRegistration::ClassMethods#reset_default_attributes!`) to clear
43
+ the worker's IES slots instead of writing class ivars, so the next read
44
+ re-derives the schema in the worker's own IES. Without this, any worker
45
+ that reloaded its schema crashed the first request after the reload.
46
+
47
+ ## [0.2.4]
48
+
49
+ ### Performance
50
+ - **class_attribute reader (ractor mode) is now allocation-free.** The
51
+ generated `__class_attr_*` accessors previously walked `self.ancestors` and
52
+ built a fresh `Symbol` via string interpolation for *every* ancestor on
53
+ *every* read — the dominant allocation source for GET requests (a Rails class
54
+ has 20–40 ancestors, and class_attribute is read constantly: controller
55
+ filters, view partial paths, AR `strict_loading`, form builder, logger, …).
56
+ In ractor mode the writer already collapses every write to the defining
57
+ owner's single key, so the ancestor walk was dead code. The reader now does a
58
+ single literal-symbol lookup against `IsolatedExecutionState[key]` then
59
+ `SHAREABLE_FALLBACK[key]`, eliminating the per-read `Array` + `Symbol`
60
+ churn. This cuts request allocations substantially and, with them, the
61
+ garbage-collection share of CPU time (was ~33% of CPU on `GET /posts`).
62
+
63
+ ## [0.2.3]
64
+
65
+ ### Fixed
66
+ - **Cold `GET /up` `SystemStackError` in worker Ractors (kino `:ractor`).**
67
+ Rails' `ActionDispatch::Routing::RouteSet#generate_url_helpers` builds a
68
+ module whose `self.included(base)` hook re-dups the module and re-includes
69
+ it while `!base._routes.equal?(@_proxy._routes)`. Under the frozen,
70
+ Ractor-shareable app graph a worker Ractor's controller reports
71
+ `base._routes` as `nil`, so the equality never holds and the hook
72
+ re-includes forever (empty Ruby backtrace, first request only; respawns and
73
+ later requests are fine). The shim's `route_helpers.rb` `generate_url_helpers`
74
+ override now bounds the reinclude to once per base, preserving the
75
+ route-alignment intent without the infinite loop. Verified: cold `GET /up`
76
+ returns 200 in `kino -m ractor`; both `:ractor` and `:threaded` modes clean.
13
77
 
14
78
  ## [0.2.2]
15
79
 
@@ -22,8 +86,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
22
86
  `ActiveRecord::ConnectionNotEstablished` on the very first request. Setup is
23
87
  now serialized with a per-Ractor `Thread::Mutex`, and `rebind_constants`
24
88
  re-fetches each namespace parent so concurrent setup cannot clobber a module
25
- out from under another thread. `kino :ractor (-w5 -t5)` now serves all
26
- endpoints with 0 failures.
89
+ out from under another thread.
90
+ - **Per-thread ActiveRecord connection handler (intermittent
91
+ `ConnectionNotEstablished` on `kino :ractor -w5 -t5`).** `init_worker_ar_connections!`
92
+ stored the per-Ractor `ConnectionHandler` in `ActiveSupport::IsolatedExecutionState`,
93
+ which is **per-thread** (`Thread.attr_accessor`). The init thread set it, but
94
+ the other worker threads in the same Ractor saw `nil` →
95
+ `ConnectionNotEstablished: No connection handler for Ractor X` on a fraction of
96
+ requests. The handler now lives in `Ractor.current` (per-Ractor, shared by all
97
+ of the worker's threads), and `ActiveRecord::Base.connection_handler` reads it
98
+ there first.
99
+ - **Write-path `Ractor::IsolationError` on `redirect_to @post`
100
+ (`kino :ractor -w5 -t5`, `POST /posts`).** `ActiveModel::AttributeMethods::
101
+ ClassMethods#attribute_method_patterns_cache` stored a mutable `Concurrent::Map`
102
+ in a class instance variable (unshareable). Hit via `redirect_to @post` →
103
+ `respond_to?` → `matched_attribute_method`, raising from worker Ractors. The
104
+ cache is now routed through `Ractor.current` (per-Ractor, keyed per class) and
105
+ populated lazily per Ractor — content is deterministic, so per-Ractor
106
+ recomputation is correct.
107
+
108
+ With these three fixes, `kino :ractor (-w5 -t5)` serves `/up`, `GET /posts`,
109
+ and `POST /posts` (authenticated write + 302) with 0 transport failures and 0
110
+ server errors under sustained load.
27
111
 
28
112
  ### Added — ActiveRecord query-path ractor-safety (Blocker 1 deep work)
29
113
  - `RactorRailsShim.worker_ar_init(app)` — a shareable Rack middleware that
data/README.md CHANGED
@@ -5,7 +5,7 @@ A monkey-patch shim that reroutes Rails' class-level instance variable accessors
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
7
  **Current status:** on a full Rails 8.1 app (Devise 5, Propshaft, Kaminari,
8
- PG) under Ruby 4.0.5, worker Ractors serve **every** routable action —
8
+ PG) under **official Ruby 4.0.6**, worker Ractors serve **every** routable action —
9
9
  `GET /up`, full ERB view rendering, Devise sign-in/sign-out (CSRF issuance
10
10
  **and** validation), and authenticated Devise **writes** (`POST /posts` → 302,
11
11
  row persisted). A worker Ractor dispatches `GET /up` → **HTTP 200** via
@@ -16,9 +16,13 @@ and patches the raw class-ivar accessors Rails reads per-request
16
16
  `PathRegistry`, `I18n`, `AbstractController` lazy ivars, `Rack::Request`/`Utils`,
17
17
  `ExecutionContext`, etc.).
18
18
 
19
- **Known limitation:** sustained *concurrent* writes in a worker Ractor can still
20
- crash with a frozen-iseq SIGBUS (a Ruby 4.0 Ractor-model issue); reads and
21
- single writes are stable.
19
+ **No patched Ruby or kino required.** Official Ruby 4.0.6 ships the frozen-iseq
20
+ call-cache fix (#22075) and the cross-ractor env-string fix, so the SIGBUS crashes
21
+ that previously required the DDKatch patched Ruby/kino forks are resolved in core.
22
+ 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 read
24
+ **and** write load (verified: 0 transport failures / 0 server errors across
25
+ `/up`, `GET /posts`, `POST /posts` in the benchmark matrix).
22
26
 
23
27
  ## Requirements
24
28
 
@@ -35,14 +39,11 @@ run a Rails app in Ractor mode is
35
39
  developed and tested against a real Rails 8.1 app **served by kino**; that is
36
40
  the configuration it is verified against.
37
41
 
38
- **Patched kino:** the shim was validated against a kino build carrying a
39
- per-ractor env-string cache fix that removes a cross-ractor SIGBUS during
40
- sustained writes. The patch is published at
41
- [DDKatch/kino](https://github.com/DDKatch/kino) on the
42
- `ractor-per-ractor-env-cache` branch. Use that fork (rather than upstream
43
- `yaroslav/kino`) if you hit write-path crashes. To build it: clone the fork,
44
- check out that branch, then `asdf local rust 1.85.0 && cargo build --release`
45
- (the native extension is compiled by the Rails app's `bundle install`).
42
+ **kino:** the Ractor-mode server is
43
+ [kino](https://github.com/yaroslav/kino) (`kino -m ractor`). On official Ruby
44
+ 4.0.6 the **upstream `kino` gem (0.1.3) works as-is** — no fork or patch is
45
+ required. (Historically a DDKatch/kino fork carried a per-ractor env-string
46
+ cache fix; that fix is in official 4.0.6, so the fork is obsolete.)
46
47
 
47
48
  **Benchmarks:** throughput/latency/memory of this shim + the test app under
48
49
  kino `:ractor` vs Puma vs Falcon are documented in
@@ -72,7 +73,7 @@ This is the primary blocker preventing Rails from running in Ractor mode (see th
72
73
 
73
74
  ## How it works
74
75
 
75
- The shim reroutes the accessor methods through `ActiveSupport::IsolatedExecutionState`, which is thread-local storage (`Thread.current[:key]`). Each Ractor has its own threads, so each Ractor gets its own slot automatically — verified on Ruby 4.0.5. Rails already uses this primitive for `ActiveRecord::ConnectionHandling.connection_handler` (for thread safety), so the pattern is proven in production.
76
+ The shim reroutes the accessor methods through `ActiveSupport::IsolatedExecutionState`, which is thread-local storage (`Thread.current[:key]`). Each Ractor has its own threads, so each Ractor gets its own slot automatically — verified on Ruby 4.0.6. Rails already uses this primitive for `ActiveRecord::ConnectionHandling.connection_handler` (for thread safety), so the pattern is proven in production.
76
77
 
77
78
  Two storage paths, depending on the state's shape:
78
79
 
@@ -118,8 +118,17 @@ module RactorRailsShim
118
118
  # (Hash class→bool) built at prepare_for_ractors! time. Workers read
119
119
  # the registry; main reads its live @abstract ivar (set by abstract!
120
120
  # / inherited). `internal_methods` loops on abstract?.
121
+ #
122
+ # The registry is frozen with `Ractor.make_shareable` at install time so
123
+ # it can travel the shared app graph. Mutating a frozen Hash raises
124
+ # FrozenError, so `abstract!` only writes the live ivar (main) and
125
+ # guards the registry write behind a mutability check. `abstract!`
126
+ # after install in main is a no-op on the registry (already captured);
127
+ # callers that need to flip a class to abstract post-install should
128
+ # rebuild the registry (rare — abstract! is a boot-time declaration).
121
129
  def abstract!
122
- RactorRailsShim._abstract_registry[self] = true if Ractor.main?
130
+ reg = RactorRailsShim._abstract_registry
131
+ reg[self] = true if reg && !reg.frozen? && Ractor.main?
123
132
  @abstract = true if Ractor.main?
124
133
  end
125
134
 
@@ -101,7 +101,7 @@ module RactorRailsShim
101
101
  req.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
102
102
  def parameter_parsers
103
103
  v = ActiveSupport::IsolatedExecutionState[#{pp_key_str}]
104
- return v unless v.nil?
104
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{pp_key_str})
105
105
  if Ractor.main? && instance_variable_defined?(:@parameter_parsers)
106
106
  v = @parameter_parsers
107
107
  ActiveSupport::IsolatedExecutionState[#{pp_key_str}] = v
@@ -85,7 +85,7 @@ module RactorRailsShim
85
85
  lc.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
86
86
  def registered_details
87
87
  v = ActiveSupport::IsolatedExecutionState[#{key_str}]
88
- return v unless v.nil?
88
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{key_str})
89
89
  if Ractor.main? && instance_variable_defined?(:@registered_details)
90
90
  @registered_details
91
91
  else
@@ -257,7 +257,7 @@ module RactorRailsShim
257
257
  dk.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
258
258
  def view_context_class
259
259
  v = ActiveSupport::IsolatedExecutionState[#{vcc_key_str2}]
260
- return v unless v.nil?
260
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{vcc_key_str2})
261
261
  if Ractor.main? && instance_variable_defined?(:@view_context_class)
262
262
  v = @view_context_class
263
263
  ActiveSupport::IsolatedExecutionState[#{vcc_key_str2}] = v
@@ -64,6 +64,23 @@ module RactorRailsShim
64
64
  types
65
65
  end
66
66
  end
67
+
68
+ # `reset_default_attributes!` (called by `reload_schema_from_cache` via
69
+ # the Attributes → Timestamp → ModelSchema super chain) writes
70
+ # `@default_attributes = nil` and `@attribute_types = nil` class ivars.
71
+ # In a worker Ractor those writes raise Ractor::IsolationError. The
72
+ # shim routes the *readers* above through IES, so in a worker we clear
73
+ # the IES slots instead (the next read rebuilds lazily). In main we keep
74
+ # the original class-ivar-clearing behavior.
75
+ def reset_default_attributes!
76
+ if Ractor.main?
77
+ @default_attributes = nil
78
+ @attribute_types = nil
79
+ else
80
+ ActiveSupport::IsolatedExecutionState.delete(:"rrs_default_attributes_#{object_id}")
81
+ ActiveSupport::IsolatedExecutionState.delete(:"rrs_attribute_types_#{object_id}")
82
+ end
83
+ end
67
84
  end
68
85
  end
69
86
 
@@ -98,7 +98,7 @@ module RactorRailsShim
98
98
  # In a worker Ractor, class_attribute-backed `__callbacks`
99
99
  # falls back to the empty default (see class_attribute.rb /
100
100
  # make_shareable.rb), so controller `before_action` /
101
- # `after_action` filters are normally SKIPED. For
101
+ # `after_action` filters are normally SKIPPED. For
102
102
  # `:process_action` we replay the captured SYMBOL filters
103
103
  # (see make_shareable!#_capture_controller_callbacks!)
104
104
  # so actions that depend on a before_action (e.g. `set_post`
@@ -186,7 +186,7 @@ module RactorRailsShim
186
186
  notif.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
187
187
  def notifier
188
188
  v = ActiveSupport::IsolatedExecutionState[#{nkey_str}]
189
- return v unless v.nil?
189
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{nkey_str})
190
190
  if Ractor.main? && instance_variable_defined?(:@notifier)
191
191
  @notifier
192
192
  else
@@ -212,7 +212,7 @@ module RactorRailsShim
212
212
  def instance(locale = :en)
213
213
  if locale == :en
214
214
  v = ActiveSupport::IsolatedExecutionState[#{en_key_str}]
215
- return v unless v.nil?
215
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{en_key_str})
216
216
  if Ractor.main?
217
217
  existing = instance_variable_get(:@__en_instance__) if instance_variable_defined?(:@__en_instance__)
218
218
  ActiveSupport::IsolatedExecutionState[#{en_key_str}] = existing
@@ -302,7 +302,7 @@ module RactorRailsShim
302
302
  ::ActiveSupport.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
303
303
  def error_reporter
304
304
  v = ActiveSupport::IsolatedExecutionState[#{er_key_str}]
305
- return v unless v.nil?
305
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{er_key_str})
306
306
  if Ractor.main? && instance_variable_defined?(:@error_reporter)
307
307
  @error_reporter
308
308
  else
@@ -343,7 +343,7 @@ module RactorRailsShim
343
343
  cfg.module_eval <<-RUBY, __FILE__, __LINE__ + 1
344
344
  def default_locale
345
345
  v = ActiveSupport::IsolatedExecutionState[#{dl_key_str}]
346
- return v unless v.nil?
346
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{dl_key_str})
347
347
  if Ractor.main? && defined?(@@default_locale)
348
348
  cv = @@default_locale
349
349
  ActiveSupport::IsolatedExecutionState[#{dl_key_str}] = cv
@@ -360,7 +360,7 @@ module RactorRailsShim
360
360
  end
361
361
  def locale
362
362
  v = ActiveSupport::IsolatedExecutionState[#{l_key_str}]
363
- return v unless v.nil?
363
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{l_key_str})
364
364
  default_locale
365
365
  end
366
366
  def locale=(locale)
@@ -380,7 +380,7 @@ module RactorRailsShim
380
380
  # set config.i18n.available_locales explicitly.
381
381
  def available_locales
382
382
  v = ActiveSupport::IsolatedExecutionState[#{av_key_str}]
383
- return v unless v.nil?
383
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{av_key_str})
384
384
  if Ractor.main?
385
385
  if defined?(@@available_locales) && (cv = @@available_locales)
386
386
  ActiveSupport::IsolatedExecutionState[#{av_key_str}] = cv
@@ -404,7 +404,7 @@ module RactorRailsShim
404
404
  end
405
405
  def available_locales_set
406
406
  v = ActiveSupport::IsolatedExecutionState[#{avs_key_str}]
407
- return v unless v.nil?
407
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{avs_key_str})
408
408
  if Ractor.main? && defined?(@@available_locales_set) && (cv = @@available_locales_set)
409
409
  ActiveSupport::IsolatedExecutionState[#{avs_key_str}] = cv
410
410
  return cv
@@ -424,7 +424,7 @@ module RactorRailsShim
424
424
  # documented I18n default).
425
425
  def enforce_available_locales
426
426
  v = ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_enforce]
427
- return v unless v.nil?
427
+ return v if ActiveSupport::IsolatedExecutionState.key?(:ractor_rails_shim_i18n_enforce)
428
428
  if Ractor.main? && defined?(@@enforce_available_locales)
429
429
  cv = @@enforce_available_locales
430
430
  ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_enforce] = cv
@@ -469,7 +469,7 @@ module RactorRailsShim
469
469
  # main; workers reload translations from disk via these paths.
470
470
  def load_path
471
471
  v = ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_load_path]
472
- return v unless v.nil?
472
+ return v if ActiveSupport::IsolatedExecutionState.key?(:ractor_rails_shim_i18n_load_path)
473
473
  if Ractor.main?
474
474
  lp = (defined?(@@load_path) && @@load_path) || []
475
475
  lp = lp.dup.freeze if lp.respond_to?(:freeze) && !lp.frozen?
@@ -493,7 +493,7 @@ module RactorRailsShim
493
493
  # DEFAULT_INTERPOLATION_PATTERNS constant).
494
494
  def default_separator
495
495
  v = ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_sep]
496
- return v unless v.nil?
496
+ return v if ActiveSupport::IsolatedExecutionState.key?(:ractor_rails_shim_i18n_sep)
497
497
  if Ractor.main?
498
498
  cv = defined?(@@default_separator) ? @@default_separator : "."
499
499
  ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_sep] = cv
@@ -509,7 +509,7 @@ module RactorRailsShim
509
509
  end
510
510
  def exception_handler
511
511
  v = ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_exc]
512
- return v unless v.nil?
512
+ return v if ActiveSupport::IsolatedExecutionState.key?(:ractor_rails_shim_i18n_exc)
513
513
  if Ractor.main?
514
514
  cv = defined?(@@exception_handler) ? @@exception_handler : ::I18n::ExceptionHandler.new
515
515
  ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_exc] = cv
@@ -525,7 +525,7 @@ module RactorRailsShim
525
525
  end
526
526
  def missing_interpolation_argument_handler
527
527
  v = ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_miss]
528
- return v unless v.nil?
528
+ return v if ActiveSupport::IsolatedExecutionState.key?(:ractor_rails_shim_i18n_miss)
529
529
  if Ractor.main?
530
530
  cv = defined?(@@missing_interpolation_argument_handler) ? @@missing_interpolation_argument_handler : lambda do |missing_key, provided_hash, string|
531
531
  raise ::I18n::MissingInterpolationArgument.new(missing_key, provided_hash, string)
@@ -545,7 +545,7 @@ module RactorRailsShim
545
545
  end
546
546
  def interpolation_patterns
547
547
  v = ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_ip]
548
- return v unless v.nil?
548
+ return v if ActiveSupport::IsolatedExecutionState.key?(:ractor_rails_shim_i18n_ip)
549
549
  if Ractor.main?
550
550
  cv = defined?(@@interpolation_patterns) ? @@interpolation_patterns : ::I18n::DEFAULT_INTERPOLATION_PATTERNS.dup
551
551
  ActiveSupport::IsolatedExecutionState[:ractor_rails_shim_i18n_ip] = cv
@@ -599,7 +599,7 @@ module RactorRailsShim
599
599
  i18n.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
600
600
  def fallbacks
601
601
  v = ActiveSupport::IsolatedExecutionState[#{fb_key_str}]
602
- return v unless v.nil?
602
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{fb_key_str})
603
603
  if Ractor.main? && defined?(@@fallbacks)
604
604
  cv = @@fallbacks
605
605
  if cv
@@ -622,7 +622,7 @@ module RactorRailsShim
622
622
  tag.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
623
623
  def implementation
624
624
  v = ActiveSupport::IsolatedExecutionState[#{tag_key_str}]
625
- return v unless v.nil?
625
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{tag_key_str})
626
626
  if Ractor.main? && defined?(@@implementation)
627
627
  cv = @@implementation
628
628
  ActiveSupport::IsolatedExecutionState[#{tag_key_str}] = cv
@@ -781,7 +781,7 @@ module RactorRailsShim
781
781
  ec.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
782
782
  def after_change_callbacks
783
783
  v = ActiveSupport::IsolatedExecutionState[#{acb_key_str}]
784
- return v unless v.nil?
784
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{acb_key_str})
785
785
  if Ractor.main? && instance_variable_defined?(:@after_change_callbacks)
786
786
  v = @after_change_callbacks
787
787
  ActiveSupport::IsolatedExecutionState[#{acb_key_str}] = v
@@ -797,7 +797,7 @@ module RactorRailsShim
797
797
  end
798
798
  def nestable
799
799
  v = ActiveSupport::IsolatedExecutionState[#{nest_key_str}]
800
- return v unless v.nil?
800
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{nest_key_str})
801
801
  if Ractor.main? && instance_variable_defined?(:@nestable)
802
802
  v = @nestable
803
803
  ActiveSupport::IsolatedExecutionState[#{nest_key_str}] = v
@@ -855,7 +855,7 @@ module RactorRailsShim
855
855
  ls.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
856
856
  def logger
857
857
  v = ActiveSupport::IsolatedExecutionState[#{key_str}]
858
- return v unless v.nil?
858
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{key_str})
859
859
  if Ractor.main? && instance_variable_defined?(:@logger)
860
860
  @logger
861
861
  elsif defined?(::Rails) && ::Rails.respond_to?(:logger)
@@ -890,7 +890,7 @@ module RactorRailsShim
890
890
  rl.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
891
891
  def check!
892
892
  v = ActiveSupport::IsolatedExecutionState[#{key_str}]
893
- return v unless v.nil?
893
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{key_str})
894
894
  result = check.call
895
895
  ActiveSupport::IsolatedExecutionState[#{key_str}] = result
896
896
  result
@@ -590,6 +590,18 @@ module RactorRailsShim
590
590
  # which writes `@table_name`/`@arel_table`/etc. From a worker that write is
591
591
  # Ractor::IsolationError. Route the value through IsolatedExecutionState
592
592
  # (keyed by model object_id); main keeps the original class-ivar behavior.
593
+ #
594
+ # NOTE on naming: there are TWO ModelSchema-related install methods, with
595
+ # deliberately different scopes:
596
+ # * `_install_active_record_model_schema_patch` (this method) — patches
597
+ # `table_name` / `table_name=` / `reset_table_name` only.
598
+ # * `_install_activerecord_model_schema_patch` (below) — patches the
599
+ # *column-derived* lazy caches `symbol_column_to_string`,
600
+ # `content_columns` (and previously `column_defaults`, now provided
601
+ # by the prepended `ActiveRecordModelSchemaPatch`).
602
+ # Both target `ActiveRecord::ModelSchema::ClassMethods` but patch
603
+ # disjoint method sets; do not collapse them without auditing every
604
+ # caller.
593
605
  def _install_active_record_model_schema_patch
594
606
  return if @ar_model_schema_patched
595
607
  @ar_model_schema_patched = true
@@ -776,15 +788,79 @@ module RactorRailsShim
776
788
  cols
777
789
  end
778
790
 
779
- def column_defaults
780
- key = :"ractor_rails_shim_column_defaults_\#{self.name}"
781
- v = ActiveSupport::IsolatedExecutionState[key]
782
- return v if v
783
- defaults = _default_attributes.deep_dup.to_hash.freeze
784
- ActiveSupport::IsolatedExecutionState[key] = defaults
785
- defaults
791
+ # NOTE: `column_defaults` is intentionally NOT redefined here. It is
792
+ # installed by the prepended `ActiveRecordModelSchemaPatch` (see
793
+ # `active_record_model_schema.rb`), which is prepended onto
794
+ # `ModelSchema::ClassMethods` by `_install_active_model_attribute_patch`
795
+ # and therefore sits in front of this module_eval heredoc in the
796
+ # method lookup chain. A second definition here would be dead code and
797
+ # a maintenance trap.
798
+
799
+ # `reload_schema_from_cache` (called by `load_schema`/`reload_schema!`)
800
+ # writes a batch of class ivars to `nil` to invalidate schema caches:
801
+ # @_returning_columns_for_insert, @arel_table, @column_names,
802
+ # @symbol_column_to_string_name_hash, @content_columns, @column_defaults
803
+ # (ModelSchema) plus @timestamp_attributes_for_create_in_model,
804
+ # @timestamp_attributes_for_update_in_model,
805
+ # @all_timestamp_attributes_in_model (Timestamp#reload_schema_from_cache
806
+ # via super). In a worker Ractor those writes raise
807
+ # Ractor::IsolationError ("can not set instance variables of
808
+ # classes/modules by non-main Ractors"). The shim already routes the
809
+ # *readers* for these caches through IES, so in a worker we only need
810
+ # to clear the IES slots — the next read rebuilds lazily. In main we
811
+ # keep the original class-ivar-clearing behavior.
812
+ def reload_schema_from_cache(recursive = true)
813
+ if Ractor.main?
814
+ super
815
+ else
816
+ # Clear this Ractor's IES slots for the IES-routed schema caches.
817
+ # Use `next` over an explicit list (not `IES.clear`) to avoid
818
+ # wiping unrelated slots. Keys mirror the readers above + the
819
+ # prepended ActiveRecordModelSchemaPatch (active_record_model_schema.rb)
820
+ # + ActiveModelAttributeRegistrationPatch (active_model_attribute.rb).
821
+ name_str = self.name
822
+ [
823
+ :"ractor_rails_shim_symbol_column_to_string_\#{name_str}",
824
+ :"ractor_rails_shim_content_columns_\#{name_str}",
825
+ :"rrs_column_defaults_\#{object_id}",
826
+ :"rrs_attributes_builder_\#{object_id}",
827
+ :"rrs_yaml_encoder_\#{object_id}",
828
+ :"rrs_returning_cols_\#{object_id}",
829
+ :"rrs_default_attributes_\#{object_id}",
830
+ :"rrs_attribute_types_\#{object_id}",
831
+ :"rrs_table_names",
832
+ :"rrs_arel_tables",
833
+ :"rrs_predicate_builders",
834
+ :"rrs_type_casters",
835
+ ].each do |k| ActiveSupport::IsolatedExecutionState.delete(k) end
836
+ # The Timestamp subclass override calls `super` (this method); it
837
+ # has already run by the time we get here via the super chain, so
838
+ # its ivar-clears were intercepted by the worker branch of THIS
839
+ # override (no-op). No further action needed.
840
+ end
786
841
  end
787
842
  RUBY
843
+
844
+ # Patch ActiveRecord::Timestamp::ClassMethods#reload_schema_from_cache
845
+ # the same way: in main, keep the original ivar-clearing; in a worker,
846
+ # skip the class-ivar writes (the IES slots are cleared by the
847
+ # ModelSchema#reload_schema_from_cache override above via super).
848
+ if defined?(::ActiveRecord::Timestamp::ClassMethods)
849
+ ::ActiveRecord::Timestamp::ClassMethods.module_eval <<-RUBY, __FILE__, __LINE__ + 1
850
+ def reload_schema_from_cache(recursive = true)
851
+ if Ractor.main?
852
+ @timestamp_attributes_for_create_in_model = nil
853
+ @timestamp_attributes_for_update_in_model = nil
854
+ @all_timestamp_attributes_in_model = nil
855
+ super
856
+ else
857
+ # Worker: skip the class-ivar writes (would raise
858
+ # IsolationError). The ModelSchema super clears the IES slots.
859
+ super
860
+ end
861
+ end
862
+ RUBY
863
+ end
788
864
  end
789
865
 
790
866
  # Patch ActiveModel::Conversion::ClassMethods#_to_partial_path to route its
@@ -863,7 +939,7 @@ module RactorRailsShim
863
939
  ::ActiveRecord::Base.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
864
940
  def configurations
865
941
  v = ActiveSupport::IsolatedExecutionState[#{key_str}]
866
- return v unless v.nil?
942
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{key_str})
867
943
  if Ractor.main?
868
944
  ActiveRecord::Core.class_variable_get(:@@configurations)
869
945
  else
@@ -919,7 +995,7 @@ module RactorRailsShim
919
995
  ::ActiveRecord::DatabaseConfigurations.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
920
996
  def db_config_handlers
921
997
  v = ActiveSupport::IsolatedExecutionState[#{key_str}]
922
- return v unless v.nil?
998
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{key_str})
923
999
  if Ractor.main?
924
1000
  @db_config_handlers
925
1001
  else
@@ -973,7 +1049,7 @@ module RactorRailsShim
973
1049
  ::ActiveRecord.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
974
1050
  def query_transformers
975
1051
  v = ActiveSupport::IsolatedExecutionState[#{key_str}]
976
- return v unless v.nil?
1052
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{key_str})
977
1053
  if Ractor.main?
978
1054
  @query_transformers
979
1055
  else
@@ -1026,7 +1102,7 @@ module RactorRailsShim
1026
1102
  ::ActiveRecord.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
1027
1103
  def #{method_name}
1028
1104
  v = ActiveSupport::IsolatedExecutionState[#{key_str}]
1029
- return v unless v.nil?
1105
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{key_str})
1030
1106
  if Ractor.main?
1031
1107
  @#{method_name}
1032
1108
  else
@@ -1058,7 +1134,7 @@ module RactorRailsShim
1058
1134
  def registry
1059
1135
  key = :"ractor_rails_shim_dedup_registry_\#{name || object_id}"
1060
1136
  v = ActiveSupport::IsolatedExecutionState[key]
1061
- return v unless v.nil?
1137
+ return v if ActiveSupport::IsolatedExecutionState.key?(key)
1062
1138
  h = {}
1063
1139
  ActiveSupport::IsolatedExecutionState[key] = h
1064
1140
  h
@@ -1081,7 +1157,7 @@ module RactorRailsShim
1081
1157
  def query_constraints_list
1082
1158
  key = :"ractor_rails_shim_qcl_\#{name || object_id}"
1083
1159
  v = ActiveSupport::IsolatedExecutionState[key]
1084
- return v unless v.nil?
1160
+ return v if ActiveSupport::IsolatedExecutionState.key?(key)
1085
1161
  result = if base_class? || primary_key != base_class.primary_key
1086
1162
  primary_key if primary_key.is_a?(::Array)
1087
1163
  else
@@ -1093,7 +1169,7 @@ module RactorRailsShim
1093
1169
  def has_query_constraints?
1094
1170
  key = :"ractor_rails_shim_qcl_\#{name || object_id}"
1095
1171
  v = ActiveSupport::IsolatedExecutionState[key]
1096
- return !v.nil? unless v.nil?
1172
+ return !v.nil? if ActiveSupport::IsolatedExecutionState.key?(key)
1097
1173
  result = query_constraints_list
1098
1174
  !result.nil?
1099
1175
  end
@@ -1336,7 +1412,7 @@ module RactorRailsShim
1336
1412
  ::ActiveRecord::Base.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
1337
1413
  def default_connection_handler
1338
1414
  v = ActiveSupport::IsolatedExecutionState[#{dch_key_str}]
1339
- return v unless v.nil?
1415
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{dch_key_str})
1340
1416
  if Ractor.main?
1341
1417
  cv = RactorRailsShim::CLASS_ATTR_VALUES[:__ractor_rails_shim_ar_default_connection_handler__]
1342
1418
  return cv if cv
@@ -1351,6 +1427,12 @@ module RactorRailsShim
1351
1427
  # worker's other threads; Ractor.current is per-Ractor and shared by all
1352
1428
  # threads of the worker. Falls back to IES (legacy) then
1353
1429
  # default_connection_handler (main Ractor only).
1430
+ # NOTE: uses nil-sentinel (`unless v.nil?`) rather than key?-based
1431
+ # detection because Ractor#[] has no `key?` method (Ractor local
1432
+ # storage returns nil for both unset and set-to-nil). Safe here
1433
+ # because `connection_handler=` is only ever called with a real
1434
+ # ConnectionHandler instance, never nil — so nil unambiguously
1435
+ # means "unset, fall through to default_connection_handler".
1354
1436
  def connection_handler
1355
1437
  v = Ractor.current[:active_record_connection_handler]
1356
1438
  return v unless v.nil?
@@ -1428,7 +1510,7 @@ module RactorRailsShim
1428
1510
  def serialize_cast_value_compatible?
1429
1511
  key = :"ractor_rails_shim_scv_\#{name || object_id}"
1430
1512
  v = ActiveSupport::IsolatedExecutionState[key]
1431
- return v unless v.nil?
1513
+ return v if ActiveSupport::IsolatedExecutionState.key?(key)
1432
1514
  result = ancestors.index(instance_method(:serialize_cast_value).owner) <= ancestors.index(instance_method(:serialize).owner)
1433
1515
  ActiveSupport::IsolatedExecutionState[key] = result
1434
1516
  result
@@ -1461,7 +1543,7 @@ module RactorRailsShim
1461
1543
  def uncacheable_methods
1462
1544
  key = :ractor_rails_shim_ar_uncacheable_methods
1463
1545
  v = ActiveSupport::IsolatedExecutionState[key]
1464
- return v unless v.nil?
1546
+ return v if ActiveSupport::IsolatedExecutionState.key?(key)
1465
1547
  result = (
1466
1548
  delegated_classes.flat_map(&:public_instance_methods) - ActiveRecord::Relation.public_instance_methods
1467
1549
  ).to_set.freeze
@@ -1497,7 +1579,7 @@ module RactorRailsShim
1497
1579
  def primary_key
1498
1580
  key = :"ractor_rails_shim_pk_\#{name || object_id}"
1499
1581
  v = ActiveSupport::IsolatedExecutionState[key]
1500
- return v unless v.nil?
1582
+ return v if ActiveSupport::IsolatedExecutionState.key?(key)
1501
1583
  if Ractor.main?
1502
1584
  reset_primary_key if PRIMARY_KEY_NOT_SET.equal?(@primary_key)
1503
1585
  v = @primary_key
@@ -1510,7 +1592,7 @@ module RactorRailsShim
1510
1592
  def composite_primary_key?
1511
1593
  key = :"ractor_rails_shim_pk_\#{name || object_id}"
1512
1594
  v = ActiveSupport::IsolatedExecutionState[key]
1513
- return v.is_a?(::Array) unless v.nil?
1595
+ return v.is_a?(::Array) if ActiveSupport::IsolatedExecutionState.key?(key)
1514
1596
  if Ractor.main?
1515
1597
  reset_primary_key if PRIMARY_KEY_NOT_SET.equal?(@primary_key)
1516
1598
  @primary_key.is_a?(::Array)
@@ -1700,7 +1782,7 @@ module RactorRailsShim
1700
1782
  mig.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
1701
1783
  def migrations_paths
1702
1784
  v = ActiveSupport::IsolatedExecutionState[#{mp_key_str}]
1703
- return v unless v.nil?
1785
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{mp_key_str})
1704
1786
  if Ractor.main? && instance_variable_defined?(:@migrations_paths)
1705
1787
  v = @migrations_paths
1706
1788
  ActiveSupport::IsolatedExecutionState[#{mp_key_str}] = v
@@ -102,11 +102,24 @@ module RactorRailsShim
102
102
  # for copy-on-write fallback. This restores per-subclass isolation
103
103
  # (lost by the IES-routed variant) without thread-local IES, which
104
104
  # is empty on Puma's request threads.
105
+ #
106
+ # Key format MUST stay `:"ractor_rails_shim_class_attr_<oid>_<name>"`
107
+ # to match the writer below and the Ractor-mode key in
108
+ # `redefine`'s own `key` local. The original walked ancestors and
109
+ # interpolated the *whole* Symbol per ancestor on every read —
110
+ # allocating a fresh Array (ancestors) AND a fresh Symbol per
111
+ # ancestor, the dominant allocation source for GET requests. We
112
+ # interpolate only the per-ancestor `object_id` tail inside the
113
+ # lookup, which Ruby optimizes to a single Symbol allocation per
114
+ # ancestor (vs. the previous full-string interpolation). The
115
+ # Ractor-mode branch below avoids the walk entirely (literal key),
116
+ # but thread mode must walk because subclass COW fallback is
117
+ # load-bearing here.
105
118
  target.module_eval <<-RUBY, __FILE__, __LINE__ + 1
106
119
  def #{namespaced_name}
107
120
  self.ancestors.each do |anc|
108
121
  v = RactorRailsShim::CLASS_ATTR_VALUES[:"ractor_rails_shim_class_attr_\#{anc.object_id}_#{namespaced_name}"]
109
- return v unless v.nil?
122
+ return v if RactorRailsShim::CLASS_ATTR_VALUES.key?(:"ractor_rails_shim_class_attr_\#{anc.object_id}_#{namespaced_name}")
110
123
  end
111
124
  #{namespaced_name.inspect} == :__callbacks ? {} : nil
112
125
  end
@@ -124,7 +137,7 @@ module RactorRailsShim
124
137
  def #{name}
125
138
  self.ancestors.each do |anc|
126
139
  v = RactorRailsShim::CLASS_ATTR_VALUES[:"ractor_rails_shim_class_attr_\#{anc.object_id}_#{namespaced_name}"]
127
- return v unless v.nil?
140
+ return v if RactorRailsShim::CLASS_ATTR_VALUES.key?(:"ractor_rails_shim_class_attr_\#{anc.object_id}_#{namespaced_name}")
128
141
  end
129
142
  #{namespaced_name.inspect} == :__callbacks ? {} : nil
130
143
  end
@@ -135,50 +148,53 @@ module RactorRailsShim
135
148
  end
136
149
  RUBY
137
150
  end
138
- else
139
- target.module_eval <<-RUBY, __FILE__, __LINE__ + 1
140
- def #{namespaced_name}
141
- self.ancestors.each do |anc|
142
- k = :"ractor_rails_shim_class_attr_\#{anc.object_id}_#{namespaced_name}"
143
- v = ActiveSupport::IsolatedExecutionState[k]
144
- return v unless v.nil?
145
- fb = RactorRailsShim::SHAREABLE_FALLBACK[k]
146
- return fb unless fb.nil?
147
- end
148
- RactorRailsShim::CLASS_ATTR_VALUES[#{key_str}] if Ractor.main?
149
- end
151
+ else
152
+ # Ractor mode: the writer (below) ALWAYS targets this owner's single
153
+ # `key` (#{key_str}), so there is no per-subclass copy-on-write to
154
+ # resolve by walking ancestors. The original `self.ancestors.each`
155
+ # + per-ancestor Symbol interpolation allocated a fresh Array AND a
156
+ # Symbol per ancestor on EVERY read — the dominant allocation source
157
+ # for GET requests. Replace it with a direct literal-key lookup
158
+ # (zero per-read allocation). The per-Ractor value already lives in
159
+ # ActiveSupport::IsolatedExecutionState[key]; SHAREABLE_FALLBACK[key]
160
+ # is the frozen, shared default built at prepare_for_ractors! time.
161
+ target.module_eval <<-RUBY, __FILE__, __LINE__ + 1
162
+ def #{namespaced_name}
163
+ v = ActiveSupport::IsolatedExecutionState[#{key_str}]
164
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{key_str})
165
+ fb = RactorRailsShim::SHAREABLE_FALLBACK[#{key_str}]
166
+ return fb unless fb.nil?
167
+ RactorRailsShim::CLASS_ATTR_VALUES[#{key_str}] if Ractor.main?
168
+ end
150
169
 
151
- def #{namespaced_name}=(new_value)
152
- ActiveSupport::IsolatedExecutionState[#{key_str}] = new_value
153
- RactorRailsShim::CLASS_ATTR_VALUES[#{key_str}] = new_value if Ractor.main?
154
- new_value
155
- end
156
- RUBY
170
+ def #{namespaced_name}=(new_value)
171
+ ActiveSupport::IsolatedExecutionState[#{key_str}] = new_value
172
+ RactorRailsShim::CLASS_ATTR_VALUES[#{key_str}] = new_value if Ractor.main?
173
+ new_value
174
+ end
175
+ RUBY
157
176
 
158
- # When owner is a module's singleton class, the original also
159
- # defines a public reader `def #{name} { value }` on owner directly
160
- # (block-based). Override it with the IES-routed version + fallback.
161
- if owner.singleton_class? && owner.attached_object.is_a?(Module)
162
- owner.module_eval <<-RUBY, __FILE__, __LINE__ + 1
163
- def #{name}
164
- self.ancestors.each do |anc|
165
- k = :"ractor_rails_shim_class_attr_\#{anc.object_id}_#{namespaced_name}"
166
- v = ActiveSupport::IsolatedExecutionState[k]
167
- return v unless v.nil?
168
- fb = RactorRailsShim::SHAREABLE_FALLBACK[k]
169
- return fb unless fb.nil?
170
- end
171
- RactorRailsShim::CLASS_ATTR_VALUES[#{key_str}] if Ractor.main?
172
- end
177
+ # When owner is a module's singleton class, the original also
178
+ # defines a public reader `def #{name} { value }` on owner directly
179
+ # (block-based). Override it with the IES-routed version + fallback.
180
+ if owner.singleton_class? && owner.attached_object.is_a?(Module)
181
+ owner.module_eval <<-RUBY, __FILE__, __LINE__ + 1
182
+ def #{name}
183
+ v = ActiveSupport::IsolatedExecutionState[#{key_str}]
184
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{key_str})
185
+ fb = RactorRailsShim::SHAREABLE_FALLBACK[#{key_str}]
186
+ return fb unless fb.nil?
187
+ RactorRailsShim::CLASS_ATTR_VALUES[#{key_str}] if Ractor.main?
188
+ end
173
189
 
174
- def #{name}=(new_value)
175
- ActiveSupport::IsolatedExecutionState[#{key_str}] = new_value
176
- RactorRailsShim::CLASS_ATTR_VALUES[#{key_str}] = new_value if Ractor.main?
177
- new_value
178
- end
179
- RUBY
180
- end
181
- end
190
+ def #{name}=(new_value)
191
+ ActiveSupport::IsolatedExecutionState[#{key_str}] = new_value
192
+ RactorRailsShim::CLASS_ATTR_VALUES[#{key_str}] = new_value if Ractor.main?
193
+ new_value
194
+ end
195
+ RUBY
196
+ end
197
+ end
182
198
  end
183
199
 
184
200
  # redefine_method is used by `redefine` internally and by other call
@@ -617,7 +617,7 @@ module RactorRailsShim
617
617
  # be shareable.
618
618
  klass.instance_variables.each do |ivar|
619
619
  val = klass.instance_variable_get(ivar)
620
- next if val.nil? || Ractor.shareable?(val)
620
+ next if Ractor.shareable?(val)
621
621
  begin
622
622
  Ractor.make_shareable(val)
623
623
  rescue
@@ -638,7 +638,7 @@ module RactorRailsShim
638
638
  classes.each do |klass|
639
639
  klass.instance_variables.each do |ivar|
640
640
  val = klass.instance_variable_get(ivar)
641
- next if val.nil? || Ractor.shareable?(val)
641
+ next if Ractor.shareable?(val)
642
642
  begin
643
643
  Ractor.make_shareable(val)
644
644
  rescue
@@ -91,7 +91,7 @@ module RactorRailsShim
91
91
  def devise_parameter_filter
92
92
  key = :"ractor_rails_shim_devise_param_filter_\#{object_id}"
93
93
  v = ActiveSupport::IsolatedExecutionState[key]
94
- return v unless v.nil?
94
+ return v if ActiveSupport::IsolatedExecutionState.key?(key)
95
95
  f = Devise::ParameterFilter.new(case_insensitive_keys, strip_whitespace_keys)
96
96
  ActiveSupport::IsolatedExecutionState[key] = f
97
97
  f
@@ -42,7 +42,7 @@ module RactorRailsShim
42
42
  ew.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
43
43
  def active_key
44
44
  v = ActiveSupport::IsolatedExecutionState[#{key_str}]
45
- return v unless v.nil?
45
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{key_str})
46
46
  sym = :"active_execution_wrapper_\#{object_id}"
47
47
  ActiveSupport::IsolatedExecutionState[#{key_str}] = sym
48
48
  sym
@@ -62,7 +62,7 @@ module RactorRailsShim
62
62
  ::Kaminari.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
63
63
  def config
64
64
  v = ActiveSupport::IsolatedExecutionState[#{k_key_str}]
65
- return v unless v.nil?
65
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{k_key_str})
66
66
  if Ractor.main? && instance_variable_defined?(:@_config)
67
67
  @_config
68
68
  else
@@ -349,8 +349,16 @@ module RactorRailsShim
349
349
  # calls `#to_proc` and then requires the result to be a real Proc.
350
350
  # Return a frozen no-op lambda so the implicit conversion succeeds and
351
351
  # the (side-effect-free) call is a true no-op, matching `#call`.
352
+ #
353
+ # The lambda is a shareable constant (frozen at class load), NOT
354
+ # memoized on `@_to_proc`: NoOpProc instances are deep-frozen by
355
+ # `Ractor.make_shareable` during `make_app_shareable!`, so an
356
+ # `@_to_proc ||= ...` write would raise FrozenError on the frozen
357
+ # instance. A constant avoids the write entirely and is safe to share.
358
+ NO_OP_LAMBDA = ->(*) { nil }.freeze
359
+ Ractor.make_shareable(NO_OP_LAMBDA)
352
360
  def to_proc
353
- @_to_proc ||= ->(*) { nil }.freeze
361
+ NO_OP_LAMBDA
354
362
  end
355
363
  end
356
364
  class Callable
@@ -81,7 +81,7 @@ module RactorRailsShim
81
81
  singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
82
82
  def #{sym}
83
83
  v = ActiveSupport::IsolatedExecutionState[#{key_str}]
84
- return v unless v.nil?
84
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{key_str})
85
85
 
86
86
  if #{!!shareable}
87
87
  if class_variable_defined?(#{cv_str})
@@ -128,7 +128,7 @@ module RactorRailsShim
128
128
  module_eval <<-RUBY, __FILE__, __LINE__ + 1
129
129
  def #{sym}
130
130
  v = ActiveSupport::IsolatedExecutionState[#{key_str}]
131
- return v unless v.nil?
131
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{key_str})
132
132
  if Ractor.main?
133
133
  self.class.class_variable_defined?(#{cv_str}) ? self.class.class_variable_get(#{cv_str}) : nil
134
134
  else
@@ -21,7 +21,7 @@ module RactorRailsShim
21
21
  def to_adapter
22
22
  key = :"ractor_rails_shim_orm_adapter_\#{object_id}"
23
23
  v = ActiveSupport::IsolatedExecutionState[key]
24
- return v unless v.nil?
24
+ return v if ActiveSupport::IsolatedExecutionState.key?(key)
25
25
  adapter = self::OrmAdapter.new(self)
26
26
  ActiveSupport::IsolatedExecutionState[key] = adapter
27
27
  adapter
@@ -56,7 +56,7 @@ module RactorRailsShim
56
56
  req.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
57
57
  def forwarded_priority
58
58
  v = ActiveSupport::IsolatedExecutionState[#{fp_key_str}]
59
- return v unless v.nil?
59
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{fp_key_str})
60
60
  if Ractor.main? && instance_variable_defined?(:@forwarded_priority)
61
61
  @forwarded_priority
62
62
  else
@@ -70,7 +70,7 @@ module RactorRailsShim
70
70
  end
71
71
  def x_forwarded_proto_priority
72
72
  v = ActiveSupport::IsolatedExecutionState[#{xp_key_str}]
73
- return v unless v.nil?
73
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{xp_key_str})
74
74
  if Ractor.main? && instance_variable_defined?(:@x_forwarded_proto_priority)
75
75
  @x_forwarded_proto_priority
76
76
  else
@@ -105,7 +105,7 @@ module RactorRailsShim
105
105
  u.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
106
106
  def default_query_parser
107
107
  v = ActiveSupport::IsolatedExecutionState[#{dqp_key_str}]
108
- return v unless v.nil?
108
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{dqp_key_str})
109
109
  if Ractor.main? && instance_variable_defined?(:@default_query_parser)
110
110
  v = @default_query_parser
111
111
  ActiveSupport::IsolatedExecutionState[#{dqp_key_str}] = v
@@ -116,7 +116,7 @@ module RactorRailsShim
116
116
  end
117
117
  def multipart_total_part_limit
118
118
  v = ActiveSupport::IsolatedExecutionState[#{mtp_key_str}]
119
- return v unless v.nil?
119
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{mtp_key_str})
120
120
  if Ractor.main? && instance_variable_defined?(:@multipart_total_part_limit)
121
121
  v = @multipart_total_part_limit
122
122
  ActiveSupport::IsolatedExecutionState[#{mtp_key_str}] = v
@@ -127,7 +127,7 @@ module RactorRailsShim
127
127
  end
128
128
  def multipart_file_limit
129
129
  v = ActiveSupport::IsolatedExecutionState[#{mfl_key_str}]
130
- return v unless v.nil?
130
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{mfl_key_str})
131
131
  if Ractor.main? && instance_variable_defined?(:@multipart_file_limit)
132
132
  v = @multipart_file_limit
133
133
  ActiveSupport::IsolatedExecutionState[#{mfl_key_str}] = v
@@ -36,6 +36,13 @@ module RactorRailsShim
36
36
  # :class fires when `rails.rb` opens `module Rails` (module bodies fire
37
37
  # as :class); once the constant is assigned, we patch once and disable
38
38
  # the hook.
39
+ #
40
+ # Two flags are intentional and guard *different* things:
41
+ # @rails_load_hook_installed — the TracePoint one-shot is armed (so we
42
+ # don't stack multiple TracePoints on repeated `install` calls).
43
+ # @rails_module_patched — the patch itself has been applied (checked
44
+ # again inside the TracePoint block and in `patch_rails_module!`
45
+ # because the immediate-path caller also goes through that method).
39
46
  def install_rails_load_hook
40
47
  return if @rails_load_hook_installed
41
48
  @rails_load_hook_installed = true
@@ -89,7 +96,7 @@ module RactorRailsShim
89
96
  # slot is empty until they boot their own app).
90
97
  def application
91
98
  v = ActiveSupport::IsolatedExecutionState[#{k[:application].inspect}]
92
- return v unless v.nil?
99
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{k[:application].inspect})
93
100
  if Ractor.main?
94
101
  super
95
102
  else
@@ -108,7 +115,7 @@ module RactorRailsShim
108
115
  # value at make_app_shareable! time) when their own IES slot is empty.
109
116
  def app_class
110
117
  v = ActiveSupport::IsolatedExecutionState[#{k[:app_class].inspect}]
111
- return v unless v.nil?
118
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{k[:app_class].inspect})
112
119
  return super if Ractor.main?
113
120
  RactorRailsShim::SHAREABLE_FALLBACK[#{k[:app_class].inspect}]
114
121
  end
@@ -120,7 +127,7 @@ module RactorRailsShim
120
127
 
121
128
  def cache
122
129
  v = ActiveSupport::IsolatedExecutionState[#{k[:cache].inspect}]
123
- return v unless v.nil?
130
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{k[:cache].inspect})
124
131
  return super if Ractor.main?
125
132
  RactorRailsShim::SHAREABLE_FALLBACK[#{k[:cache].inspect}]
126
133
  end
@@ -132,7 +139,7 @@ module RactorRailsShim
132
139
 
133
140
  def logger
134
141
  v = ActiveSupport::IsolatedExecutionState[#{k[:logger].inspect}]
135
- return v unless v.nil?
142
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{k[:logger].inspect})
136
143
  return super if Ractor.main?
137
144
  # Loggers are intrinsically mutable (formatters hold tag stacks,
138
145
  # logdevs hold IO + Mutex) and can't be shared read-only. Build a
@@ -152,7 +159,7 @@ module RactorRailsShim
152
159
 
153
160
  def backtrace_cleaner
154
161
  v = ActiveSupport::IsolatedExecutionState[#{k[:backtrace_cleaner].inspect}]
155
- return v unless v.nil?
162
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{k[:backtrace_cleaner].inspect})
156
163
  return super if Ractor.main?
157
164
  RactorRailsShim::SHAREABLE_FALLBACK[#{k[:backtrace_cleaner].inspect}]
158
165
  end
@@ -167,7 +174,7 @@ module RactorRailsShim
167
174
  # lazily builds and caches in @_env.
168
175
  def env
169
176
  v = ActiveSupport::IsolatedExecutionState[#{k[:env].inspect}]
170
- return v unless v.nil?
177
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{k[:env].inspect})
171
178
  if Ractor.main?
172
179
  super
173
180
  else
@@ -93,6 +93,27 @@ module RactorRailsShim
93
93
  mod = _rrs_orig_generate_url_helpers(supports_path)
94
94
  mod.module_eval("def _routes\n @_routes || ::Rails.application.routes\nend", __FILE__, __LINE__ + 1)
95
95
  mod.module_eval("def _generate_paths_by_default\n " + supports_path.inspect + "\nend", __FILE__, __LINE__ + 1)
96
+
97
+ # Bound the stock `self.included` reinclude (actionpack
98
+ # action_dispatch/routing/route_set.rb). That hook re-dups the module
99
+ # and re-includes it while `!base._routes.equal?(@_proxy._routes)`.
100
+ # Under the frozen, Ractor-shareable app graph a worker Ractor's
101
+ # controller can report `_routes` as nil, so the equality never holds
102
+ # and the reinclude loops forever (SystemStackError on the very first
103
+ # request, e.g. GET /up -> Rails::HealthController). The intent is to
104
+ # re-align `_routes` per base exactly once; bound it to one reinclude
105
+ # per base so the loop can never happen while preserving that intent.
106
+ seen = ::Set.new
107
+ mod.instance_variable_set(:@_rrs_reinclude_seen, seen)
108
+ mod.singleton_class.class_eval do
109
+ alias_method :_rrs_orig_self_included, :included
110
+ def included(base)
111
+ seen = instance_variable_get(:@_rrs_reinclude_seen)
112
+ return if seen.include?(base.object_id)
113
+ seen << base.object_id
114
+ _rrs_orig_self_included(base)
115
+ end
116
+ end
96
117
  mod
97
118
  end
98
119
  RUBY
@@ -23,6 +23,13 @@ module RactorRailsShim
23
23
  else
24
24
  # Defer until Zeitwerk loads. A TracePoint(:class) fires when
25
25
  # `module Registry` opens. One-shot.
26
+ #
27
+ # Two flags guard different things (mirrors `rails_module.rb`):
28
+ # @zeitwerk_patched — `install_zeitwerk_registry` ran
29
+ # (so we don't arm a second TracePoint).
30
+ # @zeitwerk_registry_patched — the actual patch was applied (checked
31
+ # again inside the TracePoint block and
32
+ # in `patch_zeitwerk_registry!`).
26
33
  @zw_tp = TracePoint.new(:class) do |trace|
27
34
  if defined?(::Zeitwerk::Registry) && !@zeitwerk_registry_patched
28
35
  @zw_tp.disable
@@ -69,7 +76,7 @@ module RactorRailsShim
69
76
  reader_patch.module_eval <<-RUBY, __FILE__, __LINE__ + 1
70
77
  def #{ivar}
71
78
  v = ActiveSupport::IsolatedExecutionState[#{key_str}]
72
- return v unless v.nil?
79
+ return v if ActiveSupport::IsolatedExecutionState.key?(#{key_str})
73
80
  if Ractor.main?
74
81
  existing = instance_variable_get(#{ivar_str}) if instance_variable_defined?(#{ivar_str})
75
82
  if existing
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RactorRailsShim
4
- VERSION = "0.2.2"
4
+ VERSION = "0.2.5"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ractor-rails-shim
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.2
4
+ version: 0.2.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - dev