ractor-rails-shim 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +101 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +265 -0
  5. data/exe/ractor-rails-check +39 -0
  6. data/lib/ractor_rails_shim/check.rb +190 -0
  7. data/lib/ractor_rails_shim/fallback_ies.rb +33 -0
  8. data/lib/ractor_rails_shim/patches/action_controller.rb +301 -0
  9. data/lib/ractor_rails_shim/patches/action_dispatch.rb +851 -0
  10. data/lib/ractor_rails_shim/patches/action_view.rb +582 -0
  11. data/lib/ractor_rails_shim/patches/active_model_attribute.rb +97 -0
  12. data/lib/ractor_rails_shim/patches/active_record_model_schema.rb +58 -0
  13. data/lib/ractor_rails_shim/patches/active_support.rb +1085 -0
  14. data/lib/ractor_rails_shim/patches/activerecord.rb +1727 -0
  15. data/lib/ractor_rails_shim/patches/class_attribute.rb +194 -0
  16. data/lib/ractor_rails_shim/patches/core.rb +806 -0
  17. data/lib/ractor_rails_shim/patches/devise.rb +172 -0
  18. data/lib/ractor_rails_shim/patches/execution_wrapper.rb +76 -0
  19. data/lib/ractor_rails_shim/patches/kaminari.rb +82 -0
  20. data/lib/ractor_rails_shim/patches/make_shareable.rb +853 -0
  21. data/lib/ractor_rails_shim/patches/mattr_accessor.rb +162 -0
  22. data/lib/ractor_rails_shim/patches/openssl.rb +58 -0
  23. data/lib/ractor_rails_shim/patches/orm_adapter.rb +32 -0
  24. data/lib/ractor_rails_shim/patches/polymorphic_routes.rb +115 -0
  25. data/lib/ractor_rails_shim/patches/propshaft.rb +110 -0
  26. data/lib/ractor_rails_shim/patches/rack.rb +160 -0
  27. data/lib/ractor_rails_shim/patches/rails_module.rb +192 -0
  28. data/lib/ractor_rails_shim/patches/route_helpers.rb +119 -0
  29. data/lib/ractor_rails_shim/patches/rubygems.rb +65 -0
  30. data/lib/ractor_rails_shim/patches/url_helpers.rb +78 -0
  31. data/lib/ractor_rails_shim/patches/warden.rb +195 -0
  32. data/lib/ractor_rails_shim/patches/zeitwerk_registry.rb +124 -0
  33. data/lib/ractor_rails_shim/patches.rb +67 -0
  34. data/lib/ractor_rails_shim/version.rb +5 -0
  35. data/lib/ractor_rails_shim/version_check.rb +88 -0
  36. data/lib/ractor_rails_shim.rb +33 -0
  37. metadata +104 -0
@@ -0,0 +1,1727 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Patch ActiveRecord's connection handler to work in per-Ractor mode.
4
+ #
5
+ # Blocker 1 (from NEXT_STEPS.md):
6
+ # ActiveRecord::Base.connection_pool calls
7
+ # default_connection_handler.retrieve_connection_pool(...)
8
+ # at connection_handling.rb:346. The default_connection_handler
9
+ # class_attribute is known-unshareable (holds callback chains with
10
+ # self-capturing Procs), so the shim's nil fallback is correct for
11
+ # callbacks BUT AR needs a live connection pool per worker. DB connections
12
+ # can't cross Ractor boundaries.
13
+ #
14
+ # Fix: Each worker Ractor gets its own ConnectionHandler with its own pool,
15
+ # seeded from the same db config as main. We patch `connection_handler` /
16
+ # `connection_handler=` / `default_connection_handler` to use per-Ractor
17
+ # storage (IES), and provide a worker-init hook that creates a new
18
+ # ConnectionHandler + establishes a connection from the same pool spec.
19
+ #
20
+ # Rails already uses IES for `connection_handler` (core.rb:132-138):
21
+ # def self.connection_handler
22
+ # ActiveSupport::IsolatedExecutionState[:active_record_connection_handler] || default_connection_handler
23
+ # end
24
+ # def self.connection_handler=(handler)
25
+ # ActiveSupport::IsolatedExecutionState[:active_record_connection_handler] = handler
26
+ # end
27
+ #
28
+ # The problem: in the main ractor, `default_connection_handler` is set via
29
+ # class_attribute (core.rb:97+248). In a worker ractor, the shim's
30
+ # class_attribute patch returns nil for default_connection_handler (it's
31
+ # in the known-unshareable skip list). So connection_handler returns nil,
32
+ # and `connection_pool` raises NoMethodError on nil.
33
+ #
34
+ # The fix: in each worker, call RactorRailsShim.init_worker_ar_connections!
35
+ # which creates a fresh ConnectionHandler and establishes connections from
36
+ # the same configurations as main. The handler is stored in IES (which
37
+ # Rails already reads). We also capture the pool spec (db configs) in the
38
+ # main ractor at prepare_for_ractors! time as a shareable constant so workers
39
+ # can read it.
40
+
41
+ module RactorRailsShim
42
+ # ActiveRecord holds several mutable container constants (Arrays / Sets of
43
+ # symbols) that are read during query execution (unscope, order, etc.).
44
+ # Reading them from a worker Ractor raises Ractor::IsolationError
45
+ # ("can not access non-shareable objects in constant ..."). Register them so
46
+ # the shim deep-freezes + const_set's a shareable twin at prepare time.
47
+ SHAREABLE_CONSTANTS.concat([
48
+ "ActiveRecord::QueryMethods::VALID_UNSCOPING_VALUES",
49
+ "ActiveRecord::QueryMethods::VALID_DIRECTIONS",
50
+ "ActiveRecord::Relation::MULTI_VALUE_METHODS",
51
+ "ActiveRecord::Relation::SINGLE_VALUE_METHODS",
52
+ "ActiveRecord::Relation::VALUE_METHODS",
53
+ "ActiveRecord::Relation::CLAUSE_METHODS",
54
+ "ActiveRecord::Relation::INVALID_METHODS_FOR_UPDATE_AND_DELETE_ALL",
55
+ "ActiveRecord::Relation::Merger::NORMAL_VALUES",
56
+ "ActiveRecord::Relation::WhereClause::ARRAY_WITH_EMPTY_STRING",
57
+ "ActiveRecord::AttributeMethods::RESTRICTED_CLASS_METHODS",
58
+ "ActiveRecord::AttributeMethods::PrimaryKey::ClassMethods::ID_ATTRIBUTE_METHODS",
59
+ "ActiveRecord::Callbacks::CALLBACKS",
60
+ "ActiveRecord::ConnectionAdapters::ColumnDefinition::OPTION_NAMES",
61
+ "ActiveRecord::ConnectionAdapters::SQLite3Adapter::DEFAULT_PRAGMAS",
62
+ "ActiveRecord::ConnectionAdapters::SQLite3Adapter::NATIVE_DATABASE_TYPES",
63
+ "ActiveRecord::ConnectionHandling::DEFAULT_ENV",
64
+ "ActiveRecord::ConnectionHandling::RAILS_ENV",
65
+ "ActiveRecord::NestedAttributes::UNASSIGNABLE_KEYS",
66
+ "ActiveRecord::Transactions::ACTIONS",
67
+ "ActiveRecord::LogSubscriber::IGNORE_PAYLOAD_NAMES",
68
+ "ActiveRecord::ExplainRegistry::Subscriber::IGNORED_PAYLOADS",
69
+ "ActiveRecord::StructuredEventSubscriber::IGNORE_PAYLOAD_NAMES",
70
+ "ActiveRecord::Encryption::Context::PROPERTIES",
71
+ "ActiveRecord::Encryption::Encryptor::DECRYPT_ERRORS",
72
+ "ActiveRecord::Encryption::Encryptor::ENCODING_ERRORS",
73
+ "ActiveRecord::Encryption::Properties::ALLOWED_VALUE_CLASSES",
74
+ "ActiveRecord::Encryption::Properties::DEFAULT_PROPERTIES",
75
+ "Arel::SelectManager::STRING_OR_SYMBOL_CLASS",
76
+ "ActiveRecord::Delegation::GeneratedRelationMethods::MUTEX",
77
+ ])
78
+
79
+ # Shareable replacements for Arel::Visitors::*::BIND_BLOCK constants.
80
+ # These are Procs (e.g. `proc { |i| "$#{i}" }`) used to format bind
81
+ # parameters in SQL. Procs can't be made shareable. We patch the `bind_block`
82
+ # method to return a shareable Callable instead (defined via string eval,
83
+ # no captured binding).
84
+ class << self
85
+ def _install_arel_bind_block_patch
86
+ return if @arel_bind_block_patched
87
+ @arel_bind_block_patched = true
88
+ _register_patch :arel_bind_block, "8.1"
89
+
90
+ # PostgreSQL: proc { |i| "$#{i}" }
91
+ if defined?(::Arel::Visitors::PostgreSQL)
92
+ ::Arel::Visitors::PostgreSQL.module_eval <<-RUBY, __FILE__, __LINE__ + 1
93
+ def bind_block
94
+ RactorRailsShim::PgBindBlock
95
+ end
96
+ RUBY
97
+ end
98
+
99
+ # ToSql (base): proc { "?" }
100
+ if defined?(::Arel::Visitors::ToSql)
101
+ ::Arel::Visitors::ToSql.module_eval <<-RUBY, __FILE__, __LINE__ + 1
102
+ def bind_block
103
+ RactorRailsShim::SqlBindBlock
104
+ end
105
+ RUBY
106
+ end
107
+ end
108
+ end
109
+
110
+ # Shareable snapshot of each AR model class's primary_key, captured at
111
+ # prepare time. Workers read this instead of the raw @primary_key class ivar
112
+ # (which is initialized to PRIMARY_KEY_NOT_SET, a BasicObject that can't be
113
+ # made shareable). Populated by _share_model_classes! in the main ractor.
114
+ AR_PRIMARY_KEYS_SHAREABLE = Ractor.make_shareable({})
115
+
116
+ # Shareable callable that replaces Arel::Visitors::PostgreSQL::BIND_BLOCK
117
+ # (a Proc `proc { |i| "$#{i}" }`). Callable cross-Ractor.
118
+ PgBindBlock = Ractor.make_shareable(Object.new.tap do |o|
119
+ def o.call(i); "$#{i}"; end
120
+ def o.to_proc; method(:call).to_proc; end
121
+ end)
122
+
123
+ # Shareable callable that replaces Arel::Visitors::ToSql::BIND_BLOCK
124
+ # (a Proc `proc { "?" }`). Callable cross-Ractor.
125
+ SqlBindBlock = Ractor.make_shareable(Object.new.tap do |o|
126
+ def o.call(_i = nil); "?"; end
127
+ def o.to_proc; method(:call).to_proc; end
128
+ end)
129
+
130
+ # Shareable snapshot of ActiveRecord::Base.configurations at
131
+ # prepare_for_ractors! time. Workers read this to establish their own
132
+ # connection pools with the same db config. Made shareable (frozen).
133
+ AR_CONFIGURATIONS_SNAPSHOT = nil
134
+
135
+ # Shareable (deep-frozen) copy of ActiveRecord::Base.configurations (the
136
+ # DatabaseConfigurations object) captured at prepare time. Workers read
137
+ # this instead of the raw `@@configurations` class variable, which a
138
+ # non-main Ractor cannot access.
139
+ AR_CONFIGURATIONS_SHAREABLE = nil
140
+
141
+ # Shareable (deep-frozen) copy of DatabaseConfigurations.db_config_handlers
142
+ # (an Array of shareable handler Procs) captured at prepare time. Workers
143
+ # read this instead of the per-Ractor class instance variable.
144
+ AR_DB_CONFIG_HANDLERS_SHAREABLE = nil
145
+
146
+ # Shareable (deep-frozen) copy of ActiveRecord.query_transformers (an Array
147
+ # of transformer classes/objects) captured at prepare time. Workers read this
148
+ # instead of the per-Ractor class instance variable.
149
+ AR_QUERY_TRANSFORMERS_SHAREABLE = nil
150
+
151
+ # Capture the db configurations from the main ractor at
152
+ # prepare_for_actors! / make_app_shareable! time. This is a shareable
153
+ # snapshot (frozen Hash of config hashes) that workers use to establish
154
+ # their own connection pools. Must run in the main Ractor.
155
+ class << self
156
+ def _capture_ar_configurations!
157
+ return if @_ar_configs_captured
158
+ @_ar_configs_captured = true
159
+ return unless defined?(::ActiveRecord::Base)
160
+
161
+ begin
162
+ # Build a plain Hash snapshot of every db config keyed by
163
+ # [env_name][config_name] => config_hash. Use
164
+ # ActiveRecord::Base.configurations.configs_for (returns DbConfig
165
+ # objects with .name, .env_name, .configuration_hash) rather than
166
+ # Rails.application.config.database_configuration (a legacy Hash whose
167
+ # shape differs between single-config apps (flat Hash = the config
168
+ # itself) and multi-config apps (nested Hash of name => config)).
169
+ cfgs = ::ActiveRecord::Base.configurations
170
+ snapshot = {}
171
+ if cfgs.respond_to?(:configs_for)
172
+ cfgs.configs_for.each do |dc|
173
+ env_name = dc.env_name
174
+ name = dc.name || "primary"
175
+ hash = dc.configuration_hash
176
+ next unless hash.is_a?(::Hash)
177
+ snapshot[env_name] ||= {}
178
+ snapshot[env_name][name] = hash.reject { |_k, v| v.nil? }
179
+ end
180
+ end
181
+ # Fallback: legacy database_configuration Hash (env => { name => config }
182
+ # OR env => flat config). Used if configs_for is unavailable.
183
+ if snapshot.empty?
184
+ raw = ::Rails.application.config.database_configuration rescue {}
185
+ raw.each do |env_name, env_configs|
186
+ next unless env_configs.is_a?(::Hash)
187
+ snapshot[env_name] ||= {}
188
+ if env_configs.key?("adapter") || env_configs.key?(:adapter)
189
+ # Flat config: the env value IS the "primary" config itself.
190
+ snapshot[env_name]["primary"] = env_configs.reject { |_k, v| v.nil? }
191
+ else
192
+ # Nested: env => { name => config }
193
+ env_configs.each do |name, config|
194
+ next unless config.is_a?(::Hash)
195
+ snapshot[env_name][name] = config.reject { |_k, v| v.nil? }
196
+ end
197
+ end
198
+ end
199
+ end
200
+ snapshot.freeze
201
+ Ractor.make_shareable(snapshot)
202
+ verbose, $VERBOSE = $VERBOSE, nil
203
+ begin
204
+ const_set(:AR_CONFIGURATIONS_SNAPSHOT, snapshot)
205
+ ensure
206
+ $VERBOSE = verbose
207
+ end
208
+ rescue => e
209
+ # Best-effort; if we can't capture configs, workers won't be able
210
+ # to auto-init connections. They can call init_worker_ar_connections!
211
+ # manually with explicit configs.
212
+ end
213
+ end
214
+
215
+ # Worker-Ractor hook: create a fresh ConnectionHandler and establish
216
+ # connections from the captured configurations snapshot. Call this in
217
+ # each worker Ractor before serving requests:
218
+ #
219
+ # Ractor.new(app) do |a|
220
+ # RactorRailsShim.init_worker_ar_connections!
221
+ # a.call(env)
222
+ # end
223
+ #
224
+ # Idempotent: safe to call multiple times (subsequent calls are no-ops
225
+ # once the handler is established). Uses Ractor.store_if_absent semantics
226
+ # via IES.
227
+ def init_worker_ar_connections!
228
+ return if Ractor.main?
229
+ return unless defined?(::ActiveRecord::Base)
230
+
231
+ key = :active_record_connection_handler
232
+ existing = ActiveSupport::IsolatedExecutionState[key]
233
+ return if existing
234
+
235
+ # Establish a fresh, per-Ractor connection handler + pool from the
236
+ # captured configurations snapshot. We call ConnectionHandler#establish_connection
237
+ # DIRECTLY (not ActiveRecord::Base.establish_connection, which writes
238
+ # the `@resolved_config` class ivar from the worker -> IsolationError).
239
+ # ConnectionHandler#establish_connection reads ActiveRecord::Base.configurations
240
+ # (now IES-routed by _install_activerecord_configurations_patch) inside
241
+ # resolve_pool_config, so it works from a worker. Best-effort per config.
242
+ snapshot = AR_CONFIGURATIONS_SNAPSHOT
243
+ if snapshot && !snapshot.empty?
244
+ env = ENV["RAILS_ENV"].presence || ENV["RACK_ENV"].presence || "development"
245
+ env_configs = snapshot[env] || snapshot.values.first || {}
246
+
247
+ handler = ::ActiveRecord::ConnectionAdapters::ConnectionHandler.new
248
+ env_configs.each do |_name, config|
249
+ begin
250
+ handler.establish_connection(config,
251
+ owner_name: ::ActiveRecord::Base,
252
+ role: ::ActiveRecord::Base.current_role || :writing,
253
+ shard: ::ActiveRecord::Base.current_shard || :default)
254
+ rescue => e
255
+ # Best-effort: if one connection fails, continue with others.
256
+ end
257
+ end
258
+
259
+ ActiveSupport::IsolatedExecutionState[key] = handler
260
+ end
261
+ end
262
+
263
+ # Blockers 3: ActiveRecord model classes cache relation-delegate classes
264
+ # in the `@relation_delegate_cache` class instance variable (set in
265
+ # `DelegateCache#initialize_relation_delegate_cache`,
266
+ # activerecord/relation/delegation.rb:31-44). The cache is a plain (mutable)
267
+ # Hash mapping each delegated class (ActiveRecord::Relation, etc.) to an
268
+ # anonymous delegate Class. From a worker Ractor, reading the class ivar
269
+ # raises Ractor::IsolationError ("can not get unshareable values from
270
+ # instance variables of classes/modules from non-main Ractors
271
+ # (@relation_delegate_cache from Post)") — even a plain `Post.page(1)`.
272
+ #
273
+ # The delegate Classes themselves ARE shareable (verified:
274
+ # Ractor.shareable?(Post::ActiveRecord_Relation) == true). Only the
275
+ # enclosing Hash is mutable (unshareable). So we deep-freeze the cache
276
+ # (Ractor.make_shareable) in the main Ractor at prepare/make-shareable
277
+ # time. A class ivar whose value is shareable is readable from a worker
278
+ # Ractor (unlike class variables, which always raise). Freezing is safe:
279
+ # the cache is populated once per class at load time and never mutated
280
+ # afterwards (each relation-delegate Class is const_set as a private
281
+ # constant on the model class).
282
+ def _install_activerecord_relation_delegate_cache_patch
283
+ return if @ar_rdc_patched
284
+ @ar_rdc_patched = true
285
+ _register_patch :activerecord_relation_delegate_cache, "8.1"
286
+ return unless defined?(::ActiveRecord::Base)
287
+
288
+ mod = ::ActiveRecord::Delegation::DelegateCache
289
+ mod.module_eval do
290
+ # The default implementation reads the delegate class from the
291
+ # `@relation_delegate_cache` *class instance variable* — an unshareable
292
+ # Hash populated lazily on the model class. From a worker Ractor that
293
+ # value is unreadable (Ractor::IsolationError: "can not get unshareable
294
+ # values from instance variables ... (@relation_delegate_cache from
295
+ # Post)"). `initialize_relation_delegate_cache` ALSO const_sets each
296
+ # delegate class onto the model (e.g. `Post::ActiveRecord_Relation`),
297
+ # and that constant is shareable. So read the delegate class via the
298
+ # constant instead of the class ivar — Ractor-safe with no per-worker
299
+ # rebuild.
300
+ def relation_delegate_class(klass)
301
+ const_get(klass.name.gsub("::", "_"))
302
+ end
303
+
304
+ # Keep building + const_setting the delegate classes (shareable), but
305
+ # stop stashing them in the unshareable `@relation_delegate_cache` ivar.
306
+ # The ivar is left a frozen empty Hash so any (now-unused) reader of it
307
+ # is still Ractor-safe.
308
+ def initialize_relation_delegate_cache
309
+ @relation_delegate_cache = {}.freeze
310
+ ::ActiveRecord::Delegation.delegated_classes.each do |k|
311
+ delegate = Class.new(k) { include ::ActiveRecord::Delegation::ClassSpecificRelation }
312
+ include_relation_methods(delegate)
313
+ mangled_name = k.name.gsub("::", "_")
314
+ const_set mangled_name, delegate
315
+ private_constant mangled_name
316
+ end
317
+ end
318
+ end
319
+
320
+ _share_relation_delegate_caches! if Ractor.main?
321
+ end
322
+
323
+ # Make every loaded AR model class's @relation_delegate_cache shareable.
324
+ # Idempotent; must run in the main Ractor after eager_load so that all
325
+ # model classes (and their caches) exist.
326
+ def _share_relation_delegate_caches!
327
+ return unless defined?(::ActiveRecord::Base)
328
+ classes = [::ActiveRecord::Base]
329
+ classes.concat(::ActiveRecord::Base.descendants) rescue nil
330
+ classes.each do |klass|
331
+ cache = klass.instance_variable_get(:@relation_delegate_cache) rescue nil
332
+ next unless cache
333
+ next if Ractor.shareable?(cache)
334
+ begin
335
+ klass.instance_variable_set(:@relation_delegate_cache,
336
+ Ractor.make_shareable(cache))
337
+ rescue => e
338
+ # Best-effort: if a cache holds an unshareable delegate class we
339
+ # can't freeze, skip it. The worker will then hit a clear error on
340
+ # the first relation method and we can patch that class specifically.
341
+ end
342
+ end
343
+ end
344
+
345
+ # ActiveRecord model classes lazily initialize many class instance
346
+ # variables on first use (e.g. @table_name, @arel_table, @predicate_builder,
347
+ # @columns_hash, @attribute_methods_module) via `@ivar ||= compute`. The
348
+ # computation is deterministic, but it WRITES the class ivar — which a
349
+ # worker Ractor cannot do (Ractor::IsolationError: "can not set instance
350
+ # variables of classes/modules by non-main Ractors").
351
+ #
352
+ # Fix: in the main Ractor, warm every model class by running representative
353
+ # queries (count / first / page), which populates all the lazy class ivars
354
+ # with their shareable-or-not values. Then make every class ivar's VALUE
355
+ # shareable (deep-freeze via Ractor.make_shareable) and write it back while
356
+ # the class is still mutable in main. A class ivar holding a shareable value
357
+ # is readable from a worker Ractor, and the worker's `||=` short-circuits
358
+ # (no write). Idempotent; must run in the main Ractor after eager_load.
359
+ def _share_model_classes!
360
+ return unless defined?(::ActiveRecord::Base)
361
+
362
+ classes = [::ActiveRecord::Base]
363
+ classes.concat(::ActiveRecord::Base.descendants) rescue nil
364
+ classes.each do |klass|
365
+ # Warm the class's lazy ivars by actually exercising the query paths
366
+ # the workers will hit. Main has a working connection handler, so this
367
+ # populates exactly the ivars a real query touches.
368
+ # Warm the class's lazy ivars by actually exercising the query paths
369
+ # the workers will hit. Main has a working connection handler, so this
370
+ # populates exactly the ivars a real query touches. Each call is
371
+ # isolated: a failure in one must not skip the rest (e.g. the private
372
+ # `relation` method or a cold connection must not prevent `table_name`
373
+ # from being set).
374
+ warm_calls = [
375
+ -> { klass.connection_pool if klass.respond_to?(:connection_pool) },
376
+ -> { klass.send(:reset_primary_key) if klass.respond_to?(:reset_primary_key, true) },
377
+ -> { klass.table_name },
378
+ -> { klass.arel_table },
379
+ -> { klass.columns_hash },
380
+ -> { klass.attribute_names },
381
+ -> { klass.attribute_types },
382
+ -> { klass.predicate_builder },
383
+ -> { klass.defined_enums if klass.respond_to?(:defined_enums) },
384
+ -> { klass.send(:relation) if klass.respond_to?(:relation, true) },
385
+ -> { klass.count rescue nil },
386
+ -> { klass.first rescue nil },
387
+ -> { klass.send(:query_constraints_list) if klass.respond_to?(:query_constraints_list, true) },
388
+ -> { if defined?(::Kaminari) && klass.respond_to?(:page)
389
+ klass.page(1).to_a rescue nil
390
+ end },
391
+ ]
392
+ warm_calls.each { |c| begin; c.call; rescue => e; end }
393
+
394
+ # Make every class ivar shareable and write it back. The class is
395
+ # still mutable here (in main), so the write is allowed.
396
+ begin
397
+ klass.instance_variables.each do |iv|
398
+ v = klass.instance_variable_get(iv) rescue nil
399
+ next unless v
400
+ next if Ractor.shareable?(v)
401
+ replacement = _shareable_ivar_replacement(v)
402
+ next unless replacement
403
+ begin
404
+ klass.instance_variable_set(iv, replacement)
405
+ rescue => e
406
+ # frozen owner — leave as-is
407
+ end
408
+ end
409
+ rescue => e
410
+ # BasicObject / frozen owners
411
+ end
412
+ end
413
+
414
+ # Capture each model's primary_key into a shareable snapshot. Workers
415
+ # read this instead of the raw @primary_key class ivar (which starts as
416
+ # PRIMARY_KEY_NOT_SET, a BasicObject that can't be made shareable).
417
+ begin
418
+ pk_map = {}
419
+ classes.each do |klass|
420
+ n = klass.name
421
+ next unless n
422
+ pk = klass.primary_key rescue next
423
+ pk_map[n] = pk if pk
424
+ end
425
+ shareable = Ractor.make_shareable(pk_map)
426
+ verbose, $VERBOSE = $VERBOSE, nil
427
+ begin
428
+ const_set(:AR_PRIMARY_KEYS_SHAREABLE, shareable)
429
+ ensure
430
+ $VERBOSE = verbose
431
+ end
432
+ rescue => e
433
+ # best-effort
434
+ end
435
+ end
436
+ # - Monitor/Mutex -> NoOpLock (never contended post-boot)
437
+ # - Concurrent::Map -> frozen Hash
438
+ # - else -> Ractor.make_shareable; if that fails (statement
439
+ # caches etc.), a frozen empty container of the same
440
+ # kind so the worker reads a shareable value (cold
441
+ # cache in workers; slower, correct). Returns nil if
442
+ # no replacement can be made.
443
+ def _shareable_ivar_replacement(v)
444
+ if v.is_a?(::Monitor) || v.is_a?(::Mutex)
445
+ Ractor.make_shareable(NoOpLock.new)
446
+ elsif defined?(::Concurrent::Map) && v.is_a?(::Concurrent::Map)
447
+ h = {}
448
+ begin
449
+ v.each_pair { |k, val| h[k] = val }
450
+ rescue => e
451
+ end
452
+ Ractor.make_shareable(h)
453
+ else
454
+ begin
455
+ Ractor.make_shareable(v)
456
+ rescue => e
457
+ case v
458
+ when ::Hash then Ractor.make_shareable({})
459
+ when ::Array then Ractor.make_shareable([])
460
+ when ::Set then Ractor.make_shareable(::Set.new)
461
+ else nil
462
+ end
463
+ end
464
+ end
465
+ end
466
+
467
+ # ActiveRecord's internal helper classes (the *Clause classes used while
468
+ # building a relation's Arel) cache a frozen "empty" singleton in a class
469
+ # instance variable via `@empty ||= new(...).freeze` (e.g.
470
+ # ActiveRecord::Relation::WhereClause#empty). Reading that class ivar from
471
+ # a worker Ractor raises Ractor::IsolationError if the value isn't
472
+ # shareable. Fix: warm `.empty` in the main Ractor (populating @empty with
473
+ # its frozen singleton), then make every class ivar on these helper
474
+ # classes shareable. Idempotent; must run in the main Ractor.
475
+ # ActiveRecord's internal helper classes/modules hold class instance
476
+ # variables that a worker Ractor reads during query building / connection
477
+ # establishment (e.g. `ActiveRecord::ConnectionAdapters.@adapters` — a Hash
478
+ # of adapter_name => [class_name, path]; the `*Clause` classes' `@empty`
479
+ # frozen singleton). A class ivar whose VALUE is shareable IS readable from
480
+ # a worker Ractor (unlike class variables), so we deep-freeze each value
481
+ # in the main Ractor and write it back (Monitor/Mutex->NoOpLock,
482
+ # Concurrent::Map->frozen Hash, etc. via _shareable_ivar_replacement).
483
+ #
484
+ # TARGETED: only specific leaf registries (not a broad ActiveRecord::*
485
+ # sweep). A broad sweep also freezes AR-railtie initializer Collections
486
+ # reachable from the app graph, which breaks make_app_shareable's
487
+ # proc-replacement (frozen containers can't be mutated to swap Procs).
488
+ def _share_active_record_internals!
489
+ return unless defined?(::ActiveRecord::Base)
490
+
491
+ # 1. Warm + freeze the *Clause `.empty` singletons.
492
+ ObjectSpace.each_object(Class) do |c|
493
+ n = c.name
494
+ next unless n && n.start_with?("ActiveRecord::Relation::") &&
495
+ n.end_with?("Clause")
496
+ begin
497
+ c.empty if c.respond_to?(:empty)
498
+ rescue => e
499
+ end
500
+ _freeze_class_ivars!(c)
501
+ end
502
+
503
+ # 2. ConnectionAdapters.@adapters (String => [class_name, path]) — read
504
+ # by `ConnectionAdapters.resolve` during establish_connection.
505
+ if defined?(::ActiveRecord::ConnectionAdapters)
506
+ _freeze_class_ivars!(::ActiveRecord::ConnectionAdapters)
507
+ end
508
+
509
+ # 3. ActiveRecord::Type — holds @default_value (a lazy singleton Value
510
+ # used as a fallback type). Warm it and freeze the class ivar.
511
+ if defined?(::ActiveRecord::Type)
512
+ ::ActiveRecord::Type.default_value rescue nil
513
+ _freeze_class_ivars!(::ActiveRecord::Type)
514
+ end
515
+ end
516
+
517
+ # Make every unshareable class ivar on `owner` shareable (deep-freeze) and
518
+ # write it back. A class ivar holding a shareable value is readable from a
519
+ # worker Ractor. Monitor/Mutex->NoOpLock; Concurrent::Map->frozen Hash;
520
+ # values that can't be frozen (Procs, TypeMap) are left as-is.
521
+ def _freeze_class_ivars!(owner)
522
+ begin
523
+ owner.instance_variables.each do |iv|
524
+ v = owner.instance_variable_get(iv) rescue nil
525
+ next unless v
526
+ next if Ractor.shareable?(v)
527
+ replacement = _shareable_ivar_replacement(v)
528
+ next unless replacement
529
+ begin
530
+ owner.instance_variable_set(iv, replacement)
531
+ rescue => e
532
+ # frozen owner — leave as-is
533
+ end
534
+ end
535
+ rescue => e
536
+ end
537
+ end
538
+
539
+ # Register + run the model-class shareability patch (Blocker: AR model
540
+ # class lazy class-ivar initialization from workers).
541
+ def _install_activerecord_model_classes_patch
542
+ return if @ar_model_classes_patched
543
+ @ar_model_classes_patched = true
544
+ _register_patch :activerecord_model_classes, "8.1"
545
+ return unless defined?(::ActiveRecord::Base)
546
+ _share_model_classes! if Ractor.main?
547
+ _share_active_record_internals! if Ractor.main?
548
+ end
549
+
550
+ # Patch ActiveRecord::ModelSchema::ClassMethods so worker Ractors do not
551
+ # write the `@table_name` (and related) class ivars on the shared model
552
+ # class. `table_name` memoizes via `reset_table_name unless
553
+ # defined?(@table_name)`, and `reset_table_name` calls `self.table_name =`
554
+ # which writes `@table_name`/`@arel_table`/etc. From a worker that write is
555
+ # Ractor::IsolationError. Route the value through IsolatedExecutionState
556
+ # (keyed by model object_id); main keeps the original class-ivar behavior.
557
+ def _install_active_record_model_schema_patch
558
+ return if @ar_model_schema_patched
559
+ @ar_model_schema_patched = true
560
+ _register_patch :active_record_model_schema, "8.1"
561
+ return unless defined?(::ActiveRecord::ModelSchema::ClassMethods)
562
+ mod = ::ActiveRecord::ModelSchema::ClassMethods
563
+ mod.module_eval do
564
+ def table_name
565
+ if Ractor.main?
566
+ reset_table_name unless defined?(@table_name)
567
+ @table_name
568
+ else
569
+ store = (ActiveSupport::IsolatedExecutionState[:rrs_table_names] ||= {})
570
+ store.fetch(object_id) { store[object_id] = compute_table_name }
571
+ end
572
+ end
573
+
574
+ def table_name=(value)
575
+ value = value && value.to_s
576
+ if Ractor.main?
577
+ if defined?(@table_name)
578
+ return if value == @table_name
579
+ reset_column_information if connected?
580
+ end
581
+ @table_name = value
582
+ @arel_table = nil
583
+ @sequence_name = nil unless @explicit_sequence_name
584
+ @predicate_builder = nil
585
+ else
586
+ (ActiveSupport::IsolatedExecutionState[:rrs_table_names] ||= {})[object_id] = value
587
+ end
588
+ end
589
+
590
+ def reset_table_name
591
+ if Ractor.main?
592
+ super
593
+ else
594
+ table_name
595
+ end
596
+ end
597
+ end
598
+ end
599
+
600
+ # Patch ActiveRecord::Core::ClassMethods#arel_table / #predicate_builder /
601
+ # #type_caster. Each memoizes an unshareable value (@arel_table is an
602
+ # Arel::Table, @predicate_builder a PredicateBuilder) on the shared model
603
+ # class. From a worker Ractor the `||=` write raises Ractor::IsolationError,
604
+ # and reading the unshareable cached value also raises. Build + cache each
605
+ # per-Ractor via IsolatedExecutionState (keyed by model object_id); main
606
+ # keeps the original class-ivar behavior.
607
+ def _install_active_record_core_patch
608
+ return if @ar_core_patched
609
+ @ar_core_patched = true
610
+ _register_patch :active_record_core, "8.1"
611
+ return unless defined?(::ActiveRecord::Core::ClassMethods)
612
+ mod = ::ActiveRecord::Core::ClassMethods
613
+ mod.module_eval do
614
+ def arel_table
615
+ if Ractor.main?
616
+ @arel_table ||= ::Arel::Table.new(table_name, klass: self)
617
+ else
618
+ store = (ActiveSupport::IsolatedExecutionState[:rrs_arel_tables] ||= {})
619
+ store.fetch(object_id) { store[object_id] = ::Arel::Table.new(table_name, klass: self) }
620
+ end
621
+ end
622
+
623
+ def predicate_builder
624
+ if Ractor.main?
625
+ @predicate_builder ||= ::ActiveRecord::PredicateBuilder.new(
626
+ ::ActiveRecord::TableMetadata.new(self, arel_table))
627
+ else
628
+ store = (ActiveSupport::IsolatedExecutionState[:rrs_predicate_builders] ||= {})
629
+ store.fetch(object_id) do
630
+ store[object_id] = ::ActiveRecord::PredicateBuilder.new(
631
+ ::ActiveRecord::TableMetadata.new(self, arel_table))
632
+ end
633
+ end
634
+ end
635
+
636
+ def type_caster
637
+ if Ractor.main?
638
+ @type_caster ||= ::ActiveRecord::TypeCaster::Map.new(self)
639
+ else
640
+ store = (ActiveSupport::IsolatedExecutionState[:rrs_type_casters] ||= {})
641
+ store.fetch(object_id) { store[object_id] = ::ActiveRecord::TypeCaster::Map.new(self) }
642
+ end
643
+ end
644
+ end
645
+ end
646
+
647
+ # Patch ActiveRecord::Inheritance::ClassMethods#finder_needs_type_condition?.
648
+ # It memoizes `@finder_needs_type_condition` (a Symbol) on the shared model
649
+ # class via `@ivar ||=`. From a worker Ractor that write raises
650
+ # Ractor::IsolationError. Route the value through IsolatedExecutionState
651
+ # (keyed by model object_id); main keeps the original class-ivar behavior.
652
+ def _install_active_record_inheritance_patch
653
+ return if @ar_inheritance_patched
654
+ @ar_inheritance_patched = true
655
+ _register_patch :active_record_inheritance, "8.1"
656
+ return unless defined?(::ActiveRecord::Inheritance::ClassMethods)
657
+ mod = ::ActiveRecord::Inheritance::ClassMethods
658
+ mod.module_eval do
659
+ def finder_needs_type_condition?
660
+ if Ractor.main?
661
+ :true == (@finder_needs_type_condition ||= descends_from_active_record? ? :false : :true)
662
+ else
663
+ store = (ActiveSupport::IsolatedExecutionState[:rrs_finder_type_cond] ||= {})
664
+ store.fetch(object_id) do
665
+ store[object_id] = descends_from_active_record? ? false : true
666
+ end
667
+ end
668
+ end
669
+ end
670
+ end
671
+ # (an ActiveModel::Name holding unfrozen, unshareable Strings) on the model
672
+ # class. From a worker Ractor that write raises Ractor::IsolationError ("can
673
+ # not set instance variables of classes/modules by non-main Ractors") and
674
+ # reading the unshareable value raises too. Route the cache through
675
+ # IsolatedExecutionState (keyed by model object_id) so each Ractor builds
676
+ # and keeps its own ActiveModel::Name without touching the shared class
677
+ # ivar.
678
+ def _install_active_model_naming_patch
679
+ return if @am_naming_patched
680
+ @am_naming_patched = true
681
+ _register_patch :active_model_naming, "8.1"
682
+ return unless defined?(::ActiveModel::Naming)
683
+ mod = ::ActiveModel::Naming
684
+ mod.module_eval do
685
+ def model_name
686
+ if Ractor.main?
687
+ @_model_name ||= _rrs_compute_model_name
688
+ else
689
+ store = (ActiveSupport::IsolatedExecutionState[:rrs_model_names] ||= {})
690
+ store[object_id] ||= _rrs_compute_model_name
691
+ end
692
+ end
693
+
694
+ private
695
+
696
+ def _rrs_compute_model_name
697
+ namespace = module_parents.detect do |n|
698
+ n.respond_to?(:use_relative_model_naming?) && n.use_relative_model_naming?
699
+ end
700
+ ::ActiveModel::Name.new(self, namespace)
701
+ end
702
+ end
703
+ end
704
+
705
+ # Patch ActiveRecord::ModelSchema::ClassMethods lazy class-ivar caches
706
+ # (`symbol_column_to_string`, `content_columns`, `column_defaults`) to
707
+ # route through IsolatedExecutionState. Each Ractor builds its own cache
708
+ # (deterministic from `columns`/`columns_hash`, which are warmed in main
709
+ # and read-only in workers). Without this, the first worker call that
710
+ # misses the cache tries to WRITE the class ivar (`@symbol_column_to_string_name_hash
711
+ # ||= ...`) and dies with `Ractor::IsolationError: can not set instance
712
+ # variables of classes/modules by non-main Ractors`. Seen via Devise's
713
+ # `clean_up_passwords` -> `respond_to?` -> `symbol_column_to_string`.
714
+ def _install_activerecord_model_schema_patch
715
+ return if @ar_model_schema_symbol_patched
716
+ @ar_model_schema_symbol_patched = true
717
+ _register_patch :activerecord_model_schema, "8.1"
718
+ return unless defined?(::ActiveRecord::ModelSchema)
719
+
720
+ ::ActiveRecord::ModelSchema::ClassMethods.module_eval <<-RUBY, __FILE__, __LINE__ + 1
721
+ def symbol_column_to_string(name_symbol)
722
+ key = :"ractor_rails_shim_symbol_column_to_string_\#{self.name}"
723
+ v = ActiveSupport::IsolatedExecutionState[key]
724
+ return v[name_symbol] if v
725
+ hash = column_names.index_by(&:to_sym)
726
+ ActiveSupport::IsolatedExecutionState[key] = hash
727
+ hash[name_symbol]
728
+ end
729
+
730
+ def content_columns
731
+ key = :"ractor_rails_shim_content_columns_\#{self.name}"
732
+ v = ActiveSupport::IsolatedExecutionState[key]
733
+ return v if v
734
+ cols = columns.reject do |c|
735
+ c.name == primary_key ||
736
+ c.name == inheritance_column ||
737
+ c.name.end_with?("_id", "_count")
738
+ end.freeze
739
+ ActiveSupport::IsolatedExecutionState[key] = cols
740
+ cols
741
+ end
742
+
743
+ def column_defaults
744
+ key = :"ractor_rails_shim_column_defaults_\#{self.name}"
745
+ v = ActiveSupport::IsolatedExecutionState[key]
746
+ return v if v
747
+ defaults = _default_attributes.deep_dup.to_hash.freeze
748
+ ActiveSupport::IsolatedExecutionState[key] = defaults
749
+ defaults
750
+ end
751
+ RUBY
752
+ end
753
+
754
+ # Patch ActiveModel::Conversion::ClassMethods#_to_partial_path to route its
755
+ # lazy class-ivar cache (`@_to_partial_path ||= ...`) through
756
+ # IsolatedExecutionState. The cache holds a deterministic String derived
757
+ # from `model_name`, so each Ractor can build its own. Without this, the
758
+ # first `render @posts` / `render post` in a worker Ractor writes the class
759
+ # ivar and dies with `Ractor::IsolationError: can not set instance variables
760
+ # of classes/modules by non-main Ractors`. Seen via ActionView's
761
+ # `CollectionRenderer#render_collection_derive_partial` -> `to_partial_path`.
762
+ def _install_active_model_conversion_patch
763
+ return if @active_model_conversion_patched
764
+ @active_model_conversion_patched = true
765
+ _register_patch :active_model_conversion, "8.1"
766
+ return unless defined?(::ActiveModel::Conversion)
767
+ amc = ::ActiveModel::Conversion
768
+ amc.module_eval <<-RUBY, __FILE__, __LINE__ + 1
769
+ module ClassMethods
770
+ def _to_partial_path
771
+ key = :"ractor_rails_shim_to_partial_path_\#{name}"
772
+ v = ActiveSupport::IsolatedExecutionState[key]
773
+ return v if v
774
+ path = if respond_to?(:model_name)
775
+ "\#{model_name.collection}/\#{model_name.element}"
776
+ else
777
+ element = ActiveSupport::Inflector.underscore(ActiveSupport::Inflector.demodulize(name))
778
+ collection = ActiveSupport::Inflector.tableize(name)
779
+ "\#{collection}/\#{element}"
780
+ end
781
+ path = path.freeze
782
+ ActiveSupport::IsolatedExecutionState[key] = path
783
+ path
784
+ end
785
+ end
786
+ RUBY
787
+ end
788
+
789
+ # Patch ActiveRecord::Core.configurations / configurations= to route the
790
+ # raw `@@configurations` class variable (which a non-main Ractor cannot
791
+ # read or write) through IsolatedExecutionState, with a shareable
792
+ # (deep-frozen) fallback for worker Ractors. Connection establishment in a
793
+ # worker (`ConnectionHandler#establish_connection` -> `resolve_pool_config`
794
+ # -> `ActiveRecord::Base.configurations`) otherwise dies on the class
795
+ # variable. Captured in the main Ractor at prepare/make-shareable time.
796
+ def _install_activerecord_configurations_patch
797
+ return if @ar_configurations_patched
798
+ @ar_configurations_patched = true
799
+ _register_patch :activerecord_configurations, "8.1"
800
+ return unless defined?(::ActiveRecord::Core)
801
+
802
+ if Ractor.main?
803
+ begin
804
+ cfg = ::ActiveRecord::Base.configurations
805
+ cfg = Ractor.make_shareable(cfg) if cfg
806
+ if cfg
807
+ verbose, $VERBOSE = $VERBOSE, nil
808
+ begin
809
+ const_set(:AR_CONFIGURATIONS_SHAREABLE, cfg)
810
+ ensure
811
+ $VERBOSE = verbose
812
+ end
813
+ end
814
+ rescue => e
815
+ # best-effort
816
+ end
817
+ end
818
+
819
+ key = :ractor_rails_shim_ar_configurations
820
+ key_str = key.inspect
821
+ # ActiveRecord::Base gets `configurations` via ActiveSupport::Concern's
822
+ # `class_methods` (it copies the method onto Base's singleton class), so
823
+ # patching Core.singleton_class alone is not enough — redefine on Base's
824
+ # singleton class directly. `@@configurations` resolves through the
825
+ # include chain (Core's class var) in the main ractor; the worker branch
826
+ # never touches the class var.
827
+ ::ActiveRecord::Base.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
828
+ def configurations
829
+ v = ActiveSupport::IsolatedExecutionState[#{key_str}]
830
+ return v unless v.nil?
831
+ if Ractor.main?
832
+ ActiveRecord::Core.class_variable_get(:@@configurations)
833
+ else
834
+ RactorRailsShim::AR_CONFIGURATIONS_SHAREABLE
835
+ end
836
+ end
837
+ def configurations=(config)
838
+ ActiveSupport::IsolatedExecutionState[#{key_str}] = config
839
+ ActiveRecord::Core.class_variable_set(:@@configurations, config) if Ractor.main?
840
+ end
841
+ RUBY
842
+ end
843
+
844
+ # Patch ActiveRecord::DatabaseConfigurations.db_config_handlers (a
845
+ # `singleton_class.attr_accessor`, i.e. a class instance variable on the
846
+ # DatabaseConfigurations class) to route through IES with a shareable
847
+ # fallback. The value is an Array of adapter-registered handler Procs
848
+ # (`register_db_config_handler { |env,name,url,config| ... }`). Class
849
+ # instance variables are per-Ractor, so a worker's slot is empty even if
850
+ # main set one — and the worker cannot read main's. The handler Procs
851
+ # themselves CAN be made shareable (verified: the sqlite3 handler captures
852
+ # only the shareable HashConfig constant), so we deep-freeze the Array +
853
+ # each Proc in main and expose it as a shareable constant the worker reads.
854
+ # `ConnectionHandler#establish_connection` -> `resolve_pool_config` ->
855
+ # `DatabaseConfigurations#resolve` -> `build_db_config_from_hash` calls
856
+ # these Procs, so they must be shareable AND callable cross-Ractor.
857
+ def _install_activerecord_db_config_handlers_patch
858
+ return if @ar_dbch_patched
859
+ @ar_dbch_patched = true
860
+ _register_patch :activerecord_db_config_handlers, "8.1"
861
+ return unless defined?(::ActiveRecord::DatabaseConfigurations)
862
+
863
+ if Ractor.main?
864
+ begin
865
+ handlers = ::ActiveRecord::DatabaseConfigurations.db_config_handlers
866
+ # Make each handler Proc shareable (freezes its binding). A shareable
867
+ # Proc is callable from any Ractor.
868
+ handlers.each { |h| Ractor.make_shareable(h) rescue nil }
869
+ shareable = Ractor.make_shareable(handlers.dup)
870
+ verbose, $VERBOSE = $VERBOSE, nil
871
+ begin
872
+ const_set(:AR_DB_CONFIG_HANDLERS_SHAREABLE, shareable)
873
+ ensure
874
+ $VERBOSE = verbose
875
+ end
876
+ rescue => e
877
+ # best-effort
878
+ end
879
+ end
880
+
881
+ key = :ractor_rails_shim_ar_db_config_handlers
882
+ key_str = key.inspect
883
+ ::ActiveRecord::DatabaseConfigurations.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
884
+ def db_config_handlers
885
+ v = ActiveSupport::IsolatedExecutionState[#{key_str}]
886
+ return v unless v.nil?
887
+ if Ractor.main?
888
+ @db_config_handlers
889
+ else
890
+ RactorRailsShim::AR_DB_CONFIG_HANDLERS_SHAREABLE
891
+ end
892
+ end
893
+ def db_config_handlers=(val)
894
+ ActiveSupport::IsolatedExecutionState[#{key_str}] = val
895
+ @db_config_handlers = val if Ractor.main?
896
+ end
897
+ RUBY
898
+ end
899
+
900
+ # Patch ActiveRecord.query_transformers to route through IES with a
901
+ # shareable fallback. `query_transformers` is a `singleton_class
902
+ # .attr_accessor` (a class instance variable on the `ActiveRecord` module)
903
+ # holding an Array of transformer objects (e.g. `ActiveRecord::QueryLogs`).
904
+ # `DatabaseStatements#preprocess_query` reads it on every query:
905
+ # `ActiveRecord.query_transformers.each { |t| t.call(sql, self) }`.
906
+ #
907
+ # Class instance variables are per-Ractor, so a worker's `@query_transformers`
908
+ # is nil (set in main at boot via `self.query_transformers = []`, then
909
+ # `<< QueryLogs` in the railtie). The transformer objects themselves ARE
910
+ # shareable (they're Classes/modules), so we deep-freeze the Array in main
911
+ # and expose it as a shareable constant the worker reads. Same pattern as
912
+ # `_install_activerecord_db_config_handlers_patch`.
913
+ def _install_activerecord_query_transformers_patch
914
+ return if @ar_query_transformers_patched
915
+ @ar_query_transformers_patched = true
916
+ _register_patch :activerecord_query_transformers, "8.1"
917
+ return unless defined?(::ActiveRecord)
918
+
919
+ if Ractor.main?
920
+ begin
921
+ transformers = ::ActiveRecord.query_transformers
922
+ transformers.each { |t| Ractor.make_shareable(t) rescue nil }
923
+ shareable = Ractor.make_shareable(transformers.dup)
924
+ verbose, $VERBOSE = $VERBOSE, nil
925
+ begin
926
+ const_set(:AR_QUERY_TRANSFORMERS_SHAREABLE, shareable)
927
+ ensure
928
+ $VERBOSE = verbose
929
+ end
930
+ rescue => e
931
+ # best-effort
932
+ end
933
+ end
934
+
935
+ key = :ractor_rails_shim_ar_query_transformers
936
+ key_str = key.inspect
937
+ ::ActiveRecord.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
938
+ def query_transformers
939
+ v = ActiveSupport::IsolatedExecutionState[#{key_str}]
940
+ return v unless v.nil?
941
+ if Ractor.main?
942
+ @query_transformers
943
+ else
944
+ RactorRailsShim::AR_QUERY_TRANSFORMERS_SHAREABLE
945
+ end
946
+ end
947
+ def query_transformers=(val)
948
+ ActiveSupport::IsolatedExecutionState[#{key_str}] = val
949
+ @query_transformers = val if Ractor.main?
950
+ end
951
+ RUBY
952
+ end
953
+
954
+ # Patch ActiveRecord module-level singleton_class.attr_accessor attributes
955
+ # (schema_cache_ignored_tables, database_cli, etc.) to route through IES
956
+ # with shareable fallbacks. These are class instance variables on the
957
+ # `ActiveRecord` module that workers can't read/write. Each is an Array or
958
+ # Hash of simple literals, so they can be deep-frozen and shared.
959
+ def _install_activerecord_module_attrs_patch
960
+ return if @ar_module_attrs_patched
961
+ @ar_module_attrs_patched = true
962
+ _register_patch :activerecord_module_attrs, "8.1"
963
+ return unless defined?(::ActiveRecord)
964
+
965
+ # [method_name, const_name] pairs. The const holds the shareable snapshot.
966
+ attrs = [
967
+ [:schema_cache_ignored_tables, :AR_SCHEMA_CACHE_IGNORED_TABLES_SHAREABLE],
968
+ [:database_cli, :AR_DATABASE_CLI_SHAREABLE],
969
+ ]
970
+
971
+ attrs.each do |method_name, const_name|
972
+ if Ractor.main?
973
+ begin
974
+ val = ::ActiveRecord.public_send(method_name)
975
+ shareable = Ractor.make_shareable(val.is_a?(::Array) ? val.dup : val)
976
+ verbose, $VERBOSE = $VERBOSE, nil
977
+ begin
978
+ const_set(const_name, shareable)
979
+ ensure
980
+ $VERBOSE = verbose
981
+ end
982
+ rescue => e
983
+ # best-effort
984
+ end
985
+ end
986
+
987
+ key = :"ractor_rails_shim_ar_#{method_name}"
988
+ key_str = key.inspect
989
+ const_str = const_name.to_s
990
+ ::ActiveRecord.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
991
+ def #{method_name}
992
+ v = ActiveSupport::IsolatedExecutionState[#{key_str}]
993
+ return v unless v.nil?
994
+ if Ractor.main?
995
+ @#{method_name}
996
+ else
997
+ RactorRailsShim::#{const_str}
998
+ end
999
+ end
1000
+ def #{method_name}=(val)
1001
+ ActiveSupport::IsolatedExecutionState[#{key_str}] = val
1002
+ @#{method_name} = val if Ractor.main?
1003
+ end
1004
+ RUBY
1005
+ end
1006
+ end
1007
+
1008
+ # Patch Deduplicable::ClassMethods#registry to route the lazy class instance
1009
+ # variable @registry through IES. `registry` returns `@registry ||= {}` —
1010
+ # a mutable Hash used to deduplicate column metadata objects. It's called
1011
+ # during schema introspection (`Post.all` -> `columns` -> `new_column_from_field`
1012
+ # -> `fetch_type_metadata` -> `Deduplicable.new` -> `deduplicate` -> `registry`).
1013
+ # The class instance variable write fails from a non-main Ractor.
1014
+ # Fix: route through IES so each Ractor builds its own registry Hash.
1015
+ def _install_activerecord_deduplicable_patch
1016
+ return if @ar_deduplicable_patched
1017
+ @ar_deduplicable_patched = true
1018
+ _register_patch :activerecord_deduplicable, "8.1"
1019
+ return unless defined?(::ActiveRecord::ConnectionAdapters::Deduplicable)
1020
+
1021
+ ::ActiveRecord::ConnectionAdapters::Deduplicable::ClassMethods.module_eval <<-RUBY, __FILE__, __LINE__ + 1
1022
+ def registry
1023
+ key = :"ractor_rails_shim_dedup_registry_\#{name || object_id}"
1024
+ v = ActiveSupport::IsolatedExecutionState[key]
1025
+ return v unless v.nil?
1026
+ h = {}
1027
+ ActiveSupport::IsolatedExecutionState[key] = h
1028
+ h
1029
+ end
1030
+ RUBY
1031
+ end
1032
+
1033
+ # Patch Persistence::ClassMethods#query_constraints_list and #has_query_constraints?
1034
+ # to route the lazy @query_constraints_list class ivar through IES.
1035
+ # `query_constraints_list` does `@query_constraints_list ||= <computation>`
1036
+ # — the class ivar write fails from a non-main Ractor. Called during
1037
+ # `Post.first` -> `ordered_relation` -> `_order_columns`.
1038
+ def _install_activerecord_query_constraints_patch
1039
+ return if @ar_query_constraints_patched
1040
+ @ar_query_constraints_patched = true
1041
+ _register_patch :activerecord_query_constraints, "8.1"
1042
+ return unless defined?(::ActiveRecord::Persistence::ClassMethods)
1043
+
1044
+ ::ActiveRecord::Persistence::ClassMethods.module_eval <<-RUBY, __FILE__, __LINE__ + 1
1045
+ def query_constraints_list
1046
+ key = :"ractor_rails_shim_qcl_\#{name || object_id}"
1047
+ v = ActiveSupport::IsolatedExecutionState[key]
1048
+ return v unless v.nil?
1049
+ result = if base_class? || primary_key != base_class.primary_key
1050
+ primary_key if primary_key.is_a?(::Array)
1051
+ else
1052
+ base_class.query_constraints_list
1053
+ end
1054
+ ActiveSupport::IsolatedExecutionState[key] = result
1055
+ result
1056
+ end
1057
+ def has_query_constraints?
1058
+ key = :"ractor_rails_shim_qcl_\#{name || object_id}"
1059
+ v = ActiveSupport::IsolatedExecutionState[key]
1060
+ return !v.nil? unless v.nil?
1061
+ result = query_constraints_list
1062
+ !result.nil?
1063
+ end
1064
+ RUBY
1065
+ end
1066
+
1067
+ # Patch ActiveRecord::ConnectionAdapters::PoolConfig#initialize to skip
1068
+ # writing to the INSTANCES ObjectSpace::WeakMap registry in non-main
1069
+ # Ractors. This is the first wall a worker hits when establishing a
1070
+ # connection (`ConnectionHandler#establish_connection` ->
1071
+ # `resolve_pool_config` -> `PoolConfig.new` -> `INSTANCES[self] = self`).
1072
+ #
1073
+ # `INSTANCES` is a `private_constant` `ObjectSpace::WeakMap`. A WeakMap is
1074
+ # intrinsically unshareable (it can't be frozen / made shareable), and a
1075
+ # non-main Ractor cannot access the constant at all (Ractor::IsolationError:
1076
+ # "can not access non-shareable objects in constant ... by non-main ractor").
1077
+ #
1078
+ # The registry is ONLY used by the class methods `discard_pools!` and
1079
+ # `disconnect_all!` (which iterate all pool configs to disconnect/reload).
1080
+ # Those are called during reloading (dev) and explicit disconnect — never
1081
+ # in a read-only production worker serving requests. So skipping the
1082
+ # registry write in workers is safe: workers manage their own per-Ractor
1083
+ # handler + pools, and the main ractor's registry stays intact for reload.
1084
+ #
1085
+ # We redefine `initialize` via string eval (no captured binding) so it's
1086
+ # callable from any Ractor. The body replicates the original exactly except
1087
+ # the final `INSTANCES[self] = self` is guarded by `Ractor.main?`. The
1088
+ # private `INSTANCES` constant is accessible via constant lookup because
1089
+ # the method is defined on PoolConfig itself.
1090
+ def _install_activerecord_pool_config_patch
1091
+ return if @ar_pool_config_patched
1092
+ @ar_pool_config_patched = true
1093
+ _register_patch :activerecord_pool_config, "8.1"
1094
+ return unless defined?(::ActiveRecord::ConnectionAdapters::PoolConfig)
1095
+
1096
+ ::ActiveRecord::ConnectionAdapters::PoolConfig.class_eval <<-RUBY, __FILE__, __LINE__ + 1
1097
+ def initialize(connection_class, db_config, role, shard)
1098
+ super()
1099
+ @server_version = nil
1100
+ self.connection_descriptor = connection_class
1101
+ @db_config = db_config
1102
+ @role = role
1103
+ @shard = shard
1104
+ @pool = nil
1105
+ INSTANCES[self] = self if Ractor.main?
1106
+ end
1107
+ RUBY
1108
+ end
1109
+
1110
+ # Patch ConnectionPool::Reaper#run to no-op in non-main Ractors.
1111
+ #
1112
+ # `ConnectionPool#initialize` (connection_pool.rb:307) calls `@reaper.run`,
1113
+ # which calls `Reaper.register_pool` (a class method). `register_pool`
1114
+ # reads/writes the Reaper class's instance variables (@mutex, @pools,
1115
+ # @threads — a Mutex, a Hash, and a Hash of Threads) and spawns a
1116
+ # background reaper thread. Class instance variables are off-limits to
1117
+ # non-main Ractors (Ractor::IsolationError), so this is the second wall a
1118
+ # worker hits during `establish_connection` -> `ConnectionPool.new`.
1119
+ #
1120
+ # The reaper is a background maintenance thread that periodically reaps
1121
+ # dead-thread connections, flushes idle connections, and keepalives stale
1122
+ # ones. In a worker Ractor this is neither safe (can't share the reaper
1123
+ # thread or its class-ivar registry across Ractors) nor essential: each
1124
+ # Ractor owns its own connection pool, and when the Ractor exits its pool
1125
+ # is garbage-collected with it. Connection health for long-lived workers
1126
+ # can be addressed later with a per-Ractor reaper if needed; for now,
1127
+ # no-op'ing registration unblocks connection establishment.
1128
+ #
1129
+ # `register_pool` is only called from `Reaper#run`, so patching `run` to
1130
+ # return early in non-main Ractors fully prevents the class-ivar access and
1131
+ # the thread spawn. The pool itself still functions normally.
1132
+ def _install_activerecord_reaper_patch
1133
+ return if @ar_reaper_patched
1134
+ @ar_reaper_patched = true
1135
+ _register_patch :activerecord_reaper, "8.1"
1136
+ return unless defined?(::ActiveRecord::ConnectionAdapters::ConnectionPool::Reaper)
1137
+
1138
+ ::ActiveRecord::ConnectionAdapters::ConnectionPool::Reaper.class_eval <<-RUBY, __FILE__, __LINE__ + 1
1139
+ def run
1140
+ return unless frequency && frequency > 0
1141
+ return unless Ractor.main?
1142
+ self.class.register_pool(pool, frequency)
1143
+ end
1144
+ RUBY
1145
+ end
1146
+
1147
+ # Patch Arel::Visitors::Visitor.dispatch_cache to route through IES.
1148
+ #
1149
+ # `dispatch_cache` is a class method that lazily initializes a class
1150
+ # instance variable: `@dispatch_cache ||= Hash.new { |hash, klass| ... }
1151
+ # .compare_by_identity`. The cache maps AST node classes to visit method
1152
+ # symbols (e.g. Arel::Nodes::SelectStatement -> :visit_Arel_Nodes_SelectStatement).
1153
+ # It's read on every Arel traversal (every query) and written on cache-miss
1154
+ # (default proc) and on method-not-found fallback (visit() rescue).
1155
+ #
1156
+ # The class instance variable `@dispatch_cache` can't be read or written by
1157
+ # a non-main Ractor (Ractor::IsolationError). The Hash also has a default
1158
+ # Proc (which is intrinsically unshareable), so the value can't be
1159
+ # frozen+shared. This is the third wall a worker hits during the first
1160
+ # query: `Post.count` -> adapter creation -> `arel_visitor` ->
1161
+ # `ToSql#initialize` -> `Visitor#initialize` -> `get_dispatch_cache` ->
1162
+ # `self.class.dispatch_cache` -> `@dispatch_cache ||= ...`.
1163
+ #
1164
+ # Fix: route through per-class IES. Each Ractor builds its own mutable
1165
+ # cache (with its own default proc) on first access. The key includes
1166
+ # `self.name` so each visitor subclass (ToSql, SQLite3, etc.) gets its own
1167
+ # cache — necessary because the method-not-found fallback
1168
+ # (`dispatch[object.class] = dispatch[superklass]`) resolves differently per
1169
+ # visitor class. The main ractor's existing `@dispatch_cache` (if any) is
1170
+ # left orphaned; new visitors created after the patch use the IES cache.
1171
+ # String-eval'd (no captured binding), callable from any Ractor.
1172
+ def _install_arel_visitor_dispatch_cache_patch
1173
+ return if @arel_visitor_patched
1174
+ @arel_visitor_patched = true
1175
+ _register_patch :arel_visitor_dispatch_cache, "8.1"
1176
+ return unless defined?(::Arel::Visitors::Visitor)
1177
+
1178
+ ::Arel::Visitors::Visitor.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
1179
+ def dispatch_cache
1180
+ key = :"ractor_rails_shim_arel_dispatch_\#{name || object_id}"
1181
+ v = ActiveSupport::IsolatedExecutionState[key]
1182
+ return v if v
1183
+ cache = Hash.new do |hash, klass|
1184
+ hash[klass] = :"visit_\#{(klass.name || "").gsub("::", "_")}"
1185
+ end.compare_by_identity
1186
+ ActiveSupport::IsolatedExecutionState[key] = cache
1187
+ cache
1188
+ end
1189
+ RUBY
1190
+ end
1191
+
1192
+ # Patch the adapter quoting caches (QUOTED_COLUMN_NAMES /
1193
+ # QUOTED_TABLE_NAMES) to use per-Ractor storage instead of the
1194
+ # unshareable `Concurrent::Map` constants.
1195
+ #
1196
+ # Each adapter (SQLite3, MySQL, PostgreSQL) defines these as
1197
+ # `Concurrent::Map.new` constants in its `Quoting` module, and the
1198
+ # `quote_column_name` / `quote_table_name` class methods lazily populate
1199
+ # them via `MAP[name] ||= <quoting_logic>.freeze`. `Concurrent::Map` is
1200
+ # intrinsically unshareable (no `#freeze`), so a worker Ractor cannot
1201
+ # access the constant at all (Ractor::IsolationError: "can not access
1202
+ # non-shareable objects in constant ..."). This is the fourth wall a worker
1203
+ # hits: during `Post.count` -> Arel traversal -> `quote_table_name` ->
1204
+ # `QUOTED_TABLE_NAMES[name] ||= ...`.
1205
+ #
1206
+ # Fix: redefine `quote_column_name` / `quote_table_name` on each adapter's
1207
+ # `Quoting::ClassMethods` module to use a per-Ractor Hash cache (stored in
1208
+ # IES, keyed by the adapter class name). Each Ractor builds its own
1209
+ # mutable cache on first access. The quoting logic is replicated per
1210
+ # adapter (it's simple, stable string manipulation). String-eval'd (no
1211
+ # captured binding), callable from any Ractor.
1212
+ def _install_activerecord_quoting_cache_patch
1213
+ return if @ar_quoting_patched
1214
+ @ar_quoting_patched = true
1215
+ _register_patch :activerecord_quoting_cache, "8.1"
1216
+
1217
+ # [module_path, column_logic, table_logic] per adapter.
1218
+ adapters = [
1219
+ ["ActiveRecord::ConnectionAdapters::SQLite3::Quoting",
1220
+ %q{%Q("#{name.to_s.gsub('"', '""')}").freeze},
1221
+ %q{%Q("#{name.to_s.gsub('"', '""').gsub(".", "\".\"")}").freeze}],
1222
+ ["ActiveRecord::ConnectionAdapters::MySQL::Quoting",
1223
+ %q{"`#{name.to_s.gsub('`', '``')}`".freeze},
1224
+ %q{"`#{name.to_s.gsub('`', '``').gsub(".", "`.`")}`".freeze}],
1225
+ ["ActiveRecord::ConnectionAdapters::PostgreSQL::Quoting",
1226
+ %q{::PG::Connection.quote_ident(name.to_s).freeze},
1227
+ %q{::ActiveRecord::ConnectionAdapters::PostgreSQL::Utils.extract_schema_qualified_name(name.to_s).quoted.freeze}],
1228
+ ]
1229
+
1230
+ adapters.each do |mod_path, column_logic, table_logic|
1231
+ mod = begin
1232
+ mod_path.split("::").inject(Object) { |ns, n| ns.const_get(n, false) }
1233
+ rescue
1234
+ nil
1235
+ end
1236
+ next unless mod
1237
+ klass_mod = mod.const_get(:ClassMethods, false) rescue next
1238
+ next unless klass_mod.method_defined?(:quote_column_name)
1239
+
1240
+ klass_mod.module_eval <<-RUBY, __FILE__, __LINE__ + 1
1241
+ def quote_column_name(name)
1242
+ key = :"ractor_rails_shim_quoted_cols_\#{self.name}"
1243
+ cache = ActiveSupport::IsolatedExecutionState[key]
1244
+ cache ||= (ActiveSupport::IsolatedExecutionState[key] = {})
1245
+ cache[name] ||= (#{column_logic})
1246
+ end
1247
+
1248
+ def quote_table_name(name)
1249
+ key = :"ractor_rails_shim_quoted_tables_\#{self.name}"
1250
+ cache = ActiveSupport::IsolatedExecutionState[key]
1251
+ cache ||= (ActiveSupport::IsolatedExecutionState[key] = {})
1252
+ cache[name] ||= (#{table_logic})
1253
+ end
1254
+ RUBY
1255
+ end
1256
+ end
1257
+
1258
+ # Patch ActiveRecord::Base to route default_connection_handler through
1259
+ # IES, and ensure connection_handler returns the per-Ractor handler.
1260
+ # In the main ractor, falls back to the original default_connection_handler
1261
+ # (set at core.rb:248). In workers, falls back to nil (correct — workers
1262
+ # must call init_worker_ar_connections! to establish their own handler).
1263
+ # Also patches retrieve_connection / connected? / connection_pool to
1264
+ # tolerate nil handler (raise a clear error message instead of
1265
+ # NoMethodError on nil).
1266
+ def _install_activerecord_connection_handler_patch
1267
+ return if @ar_conn_handler_patched
1268
+ @ar_conn_handler_patched = true
1269
+ _register_patch :activerecord_connection_handler, "8.1"
1270
+ return unless defined?(::ActiveRecord::ConnectionHandling)
1271
+
1272
+ # Capture configs at install time if AR is already loaded (main ractor).
1273
+ _capture_ar_configurations! if Ractor.main?
1274
+
1275
+ # Capture the main ractor's default_connection_handler value BEFORE we
1276
+ # override the method below (the override shadows the original
1277
+ # class_attribute reader). The class_attribute reader (already patched
1278
+ # by the shim) routes through IES, so read the value now. Store it in
1279
+ # CLASS_ATTR_VALUES so the patched reader can find it, and seed IES so
1280
+ # connection_handler finds it immediately.
1281
+ dch_key = :ractor_rails_shim_ar_default_connection_handler
1282
+ dch_key_str = dch_key.inspect
1283
+ if Ractor.main?
1284
+ begin
1285
+ orig_handler = ::ActiveRecord::Base.default_connection_handler
1286
+ if orig_handler
1287
+ RactorRailsShim::CLASS_ATTR_VALUES[:__ractor_rails_shim_ar_default_connection_handler__] = orig_handler
1288
+ ActiveSupport::IsolatedExecutionState[dch_key] = orig_handler
1289
+ end
1290
+ rescue => e
1291
+ # Best-effort
1292
+ end
1293
+ end
1294
+
1295
+ # Patch default_connection_handler to route through IES.
1296
+ # The class_attribute reader for default_connection_handler is already
1297
+ # patched by the shim (it's in the known-unshareable skip list → nil
1298
+ # in workers). We override the class method to return the per-Ractor
1299
+ # handler if set, then fall back to the original (main only) or nil.
1300
+ ::ActiveRecord::Base.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
1301
+ def default_connection_handler
1302
+ v = ActiveSupport::IsolatedExecutionState[#{dch_key_str}]
1303
+ return v unless v.nil?
1304
+ if Ractor.main?
1305
+ cv = RactorRailsShim::CLASS_ATTR_VALUES[:__ractor_rails_shim_ar_default_connection_handler__]
1306
+ return cv if cv
1307
+ end
1308
+ nil
1309
+ end
1310
+ def default_connection_handler=(val)
1311
+ ActiveSupport::IsolatedExecutionState[#{dch_key_str}] = val
1312
+ end
1313
+ RUBY
1314
+
1315
+ # Also ensure connection_handler (which Rails already routes through IES
1316
+ # at core.rb:132-138) works. Rails' implementation:
1317
+ # def self.connection_handler
1318
+ # ActiveSupport::IsolatedExecutionState[:active_record_connection_handler] || default_connection_handler
1319
+ # end
1320
+ # This is already correct — if the worker sets the IES key via
1321
+ # init_worker_ar_connections!, connection_handler returns it. If not,
1322
+ # it falls back to default_connection_handler (nil in workers).
1323
+ #
1324
+ # We just need to make sure connection_pool / retrieve_connection
1325
+ # give a clear error message when the handler is nil (instead of
1326
+ # NoMethodError: undefined method `retrieve_connection_pool' for nil).
1327
+ ::ActiveRecord::ConnectionHandling.module_eval <<-RUBY, __FILE__, __LINE__ + 1
1328
+ def connection_pool
1329
+ handler = connection_handler
1330
+ unless handler
1331
+ raise ActiveRecord::ConnectionNotEstablished,
1332
+ "No connection handler for Ractor \#{Ractor.current.object_id}. " \
1333
+ "Call RactorRailsShim.init_worker_ar_connections! in each worker " \
1334
+ "Ractor before serving requests."
1335
+ end
1336
+ handler.retrieve_connection_pool(connection_specification_name, role: current_role, shard: current_shard, strict: true)
1337
+ end
1338
+ def retrieve_connection
1339
+ handler = connection_handler
1340
+ unless handler
1341
+ raise ActiveRecord::ConnectionNotEstablished,
1342
+ "No connection handler for Ractor \#{Ractor.current.object_id}. " \
1343
+ "Call RactorRailsShim.init_worker_ar_connections! in each worker " \
1344
+ "Ractor before serving requests."
1345
+ end
1346
+ handler.retrieve_connection(connection_specification_name, role: current_role, shard: current_shard)
1347
+ end
1348
+ def connected?
1349
+ handler = connection_handler
1350
+ return false unless handler
1351
+ handler.connected?(connection_specification_name, role: current_role, shard: current_shard)
1352
+ end
1353
+ RUBY
1354
+ end
1355
+
1356
+ # Patch ActiveModel::Type::SerializeCastValue::ClassMethods
1357
+ # #serialize_cast_value_compatible? to route the lazy class instance
1358
+ # variable @serialize_cast_value_compatible through IES.
1359
+ #
1360
+ # This method lazily caches a boolean: `return @x if defined?(@x); @x =
1361
+ # <computation>`. It's called during type-map initialization (every
1362
+ # adapter's `initialize_type_map` creates type objects whose constructors
1363
+ # eagerly call this to precompute the value). The class instance variable
1364
+ # write fails from a non-main Ractor (Ractor::IsolationError: "can not set
1365
+ # instance variables of classes/modules by non-main Ractors").
1366
+ #
1367
+ # Fix: route through IES so each Ractor computes + caches its own value.
1368
+ # The computation is deterministic (compares ancestor positions of two
1369
+ # methods), so per-Ractor recomputation yields the same result. Each
1370
+ # including class gets its own IES key (keyed by `self.name`). String-eval'd
1371
+ # (no captured binding), callable from any Ractor.
1372
+ def _install_activerecord_serialize_cast_value_patch
1373
+ return if @ar_serialize_cast_patched
1374
+ @ar_serialize_cast_patched = true
1375
+ _register_patch :activerecord_serialize_cast_value, "8.1"
1376
+ return unless defined?(::ActiveModel::Type::SerializeCastValue)
1377
+
1378
+ ::ActiveModel::Type::SerializeCastValue::ClassMethods.module_eval <<-RUBY, __FILE__, __LINE__ + 1
1379
+ def serialize_cast_value_compatible?
1380
+ key = :"ractor_rails_shim_scv_\#{name || object_id}"
1381
+ v = ActiveSupport::IsolatedExecutionState[key]
1382
+ return v unless v.nil?
1383
+ result = ancestors.index(instance_method(:serialize_cast_value).owner) <= ancestors.index(instance_method(:serialize).owner)
1384
+ ActiveSupport::IsolatedExecutionState[key] = result
1385
+ result
1386
+ end
1387
+ RUBY
1388
+ end
1389
+
1390
+ # Patch ActiveRecord::Delegation.uncacheable_methods to route the lazy
1391
+ # class instance variable @uncacheable_methods through IES.
1392
+ #
1393
+ # `uncacheable_methods` is a class method on the `Delegation` module:
1394
+ # `@uncacheable_methods ||= (delegated_classes.flat_map(&:public_instance_methods)
1395
+ # - Relation.public_instance_methods).to_set.freeze`. It's read during
1396
+ # `method_missing` on relation delegate classes (e.g. when Kaminari calls
1397
+ # `Post.page(1).per(10)` — `per` isn't a standard Relation method, so
1398
+ # `ClassSpecificRelation#method_missing` checks `uncacheable_methods` to
1399
+ # decide whether to delegate). The class instance variable write fails
1400
+ # from a non-main Ractor (Ractor::IsolationError).
1401
+ #
1402
+ # Fix: route through IES so each Ractor computes + caches its own Set.
1403
+ # The computation is deterministic (same delegated_classes everywhere).
1404
+ # String-eval'd (no captured binding), callable from any Ractor.
1405
+ def _install_activerecord_delegation_patch
1406
+ return if @ar_delegation_patched
1407
+ @ar_delegation_patched = true
1408
+ _register_patch :activerecord_delegation, "8.1"
1409
+ return unless defined?(::ActiveRecord::Delegation)
1410
+
1411
+ ::ActiveRecord::Delegation.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
1412
+ def uncacheable_methods
1413
+ key = :ractor_rails_shim_ar_uncacheable_methods
1414
+ v = ActiveSupport::IsolatedExecutionState[key]
1415
+ return v unless v.nil?
1416
+ result = (
1417
+ delegated_classes.flat_map(&:public_instance_methods) - ActiveRecord::Relation.public_instance_methods
1418
+ ).to_set.freeze
1419
+ ActiveSupport::IsolatedExecutionState[key] = result
1420
+ result
1421
+ end
1422
+ RUBY
1423
+ end
1424
+
1425
+ # Patch ActiveRecord::AttributeMethods::PrimaryKey#primary_key and
1426
+ # #composite_primary_key? to not read the PRIMARY_KEY_NOT_SET constant.
1427
+ #
1428
+ # The original code: `reset_primary_key if PRIMARY_KEY_NOT_SET.equal?(@primary_key)`
1429
+ # reads the constant on every call. PRIMARY_KEY_NOT_SET is a BasicObject
1430
+ # (can't be frozen, can't be made shareable), so reading the constant from
1431
+ # a worker Ractor raises Ractor::IsolationError — even if @primary_key is
1432
+ # already set to the real value.
1433
+ #
1434
+ # Fix: replace the sentinel check with a shareable-snapshot lookup. At
1435
+ # _share_model_classes! time, each model's primary_key is warmed in main
1436
+ # and stored in AR_PRIMARY_KEYS_SHAREABLE (a frozen Hash). The patched
1437
+ # primary_key method checks IES first (per-Ractor), then the shareable
1438
+ # snapshot, then falls back to the original logic in the main ractor.
1439
+ # Workers never read the constant. String-eval'd (no captured binding).
1440
+ def _install_activerecord_primary_key_patch
1441
+ return if @ar_primary_key_patched
1442
+ @ar_primary_key_patched = true
1443
+ _register_patch :activerecord_primary_key, "8.1"
1444
+ return unless defined?(::ActiveRecord::AttributeMethods::PrimaryKey::ClassMethods)
1445
+
1446
+ mod = ::ActiveRecord::AttributeMethods::PrimaryKey::ClassMethods
1447
+ mod.module_eval <<-RUBY, __FILE__, __LINE__ + 1
1448
+ def primary_key
1449
+ key = :"ractor_rails_shim_pk_\#{name || object_id}"
1450
+ v = ActiveSupport::IsolatedExecutionState[key]
1451
+ return v unless v.nil?
1452
+ if Ractor.main?
1453
+ reset_primary_key if PRIMARY_KEY_NOT_SET.equal?(@primary_key)
1454
+ v = @primary_key
1455
+ ActiveSupport::IsolatedExecutionState[key] = v
1456
+ v
1457
+ else
1458
+ RactorRailsShim::AR_PRIMARY_KEYS_SHAREABLE[name]
1459
+ end
1460
+ end
1461
+ def composite_primary_key?
1462
+ key = :"ractor_rails_shim_pk_\#{name || object_id}"
1463
+ v = ActiveSupport::IsolatedExecutionState[key]
1464
+ return v.is_a?(::Array) unless v.nil?
1465
+ if Ractor.main?
1466
+ reset_primary_key if PRIMARY_KEY_NOT_SET.equal?(@primary_key)
1467
+ @primary_key.is_a?(::Array)
1468
+ else
1469
+ pk = RactorRailsShim::AR_PRIMARY_KEYS_SHAREABLE[name]
1470
+ pk.is_a?(::Array)
1471
+ end
1472
+ end
1473
+ RUBY
1474
+ end
1475
+
1476
+ # `ActiveRecord::Base#cached_find_by_statement` reads
1477
+ # `@find_by_statement_cache[connection.prepared_statements]` (a Hash whose
1478
+ # values are `Concurrent::Map`s) and calls `cache.compute_if_absent(key)`.
1479
+ # `Concurrent::Map` is unshareable, so `make_app_shareable!` replaces the
1480
+ # maps with frozen Hashes whose values end up `nil` — and `Hash` has no
1481
+ # `compute_if_absent` anyway. In a worker Ractor this raises
1482
+ # `NoMethodError: undefined method 'compute_if_absent' for nil`, breaking
1483
+ # `find` / `find_by` / `take` (but not `where`, which doesn't use the
1484
+ # cache). Fix: in non-main Ractors, build the per-find-statement cache in
1485
+ # `IsolatedExecutionState` (per-Ractor, mutable) keyed by the model class
1486
+ # and `connection.prepared_statements`. Main keeps the original
1487
+ # `Concurrent::Map`-backed behavior via `super`.
1488
+ def _install_activerecord_find_by_cache_patch
1489
+ return if @activerecord_find_by_cache_patched
1490
+ @activerecord_find_by_cache_patched = true
1491
+ _register_patch :activerecord_find_by_cache, "8.1"
1492
+ return unless defined?(::ActiveRecord::Base)
1493
+
1494
+ ::ActiveRecord::Base.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
1495
+ def cached_find_by_statement(connection, key, &block)
1496
+ return super if Ractor.main?
1497
+ cache = (ActiveSupport::IsolatedExecutionState[:"ractor_rails_shim_find_by_cache_\#{object_id}"] ||= {})
1498
+ prepared = connection.prepared_statements
1499
+ sub = (cache[prepared] ||= {})
1500
+ if sub.key?(key)
1501
+ sub[key]
1502
+ else
1503
+ sub[key] = ::ActiveRecord::StatementCache.create(connection, &block)
1504
+ end
1505
+ end
1506
+ RUBY
1507
+ end
1508
+
1509
+ # A shareable Rack middleware that ensures each worker Ractor establishes
1510
+ # its own ActiveRecord connection handler on the first request it serves.
1511
+ # Kino's `:ractor` mode has no per-worker init hook, so the connection
1512
+ # must be initialized lazily inside the worker Ractor's request path.
1513
+ # `init_worker_ar_connections!` is idempotent (it early-returns once the
1514
+ # per-Ractor IES slot holds a handler), so calling it on every request is
1515
+ # cheap after the first. The wrapper holds only `@app` (shareable), so the
1516
+ # wrapper itself is `Ractor.make_shareable`.
1517
+ #
1518
+ # Usage in a kino `config_ractor.ru`:
1519
+ # app = RactorRailsShim.make_app_shareable!(Rails.application)
1520
+ # app = RactorRailsShim.worker_ar_init(app)
1521
+ # run app
1522
+ class ArWorkerInitWrapper
1523
+ def initialize(app)
1524
+ @app = app
1525
+ end
1526
+
1527
+ def call(env)
1528
+ RactorRailsShim.init_worker_ar_connections!
1529
+ @app.call(env)
1530
+ end
1531
+ end
1532
+
1533
+ # Wrap `app` so every worker Ractor initializes its ActiveRecord
1534
+ # connections on first request. Returns a shareable wrapper.
1535
+ def worker_ar_init(app)
1536
+ ArWorkerInitWrapper.new(app)
1537
+ end
1538
+
1539
+ # Patch ActiveRecord::QueryLogs#tag_content. It reads the `@handlers` and
1540
+ # `@formatter` class ivars (populated from config.active_record.query_log_tags
1541
+ # during boot) on every SQL statement, so a worker Ractor raises
1542
+ # Ractor::IsolationError. `cached_comment` is a thread_mattr_accessor (already
1543
+ # Ractor-safe), so only the handlers/formatter need handling. Capture a
1544
+ # shareable snapshot in main (snapshot_query_logs!, post-boot) and have
1545
+ # workers build the comment from it — query-log tags then work in workers
1546
+ # exactly as in dev's main Ractor.
1547
+ def _install_activerecord_query_logs_patch
1548
+ return if @query_logs_patched
1549
+ @query_logs_patched = true
1550
+ _register_patch :query_logs, "8.1"
1551
+ return unless defined?(::ActiveRecord::QueryLogs)
1552
+ ql = ::ActiveRecord::QueryLogs
1553
+ # tag_content is defined as a SINGLETON method on ActiveRecord::QueryLogs
1554
+ # (not an instance method), so alias the singleton method (not an
1555
+ # instance one) and fall back to it for the main Ractor / when the
1556
+ # snapshot is unavailable.
1557
+ ql.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
1558
+ alias_method :__ractors_original_tag_content, :tag_content
1559
+ def tag_content(connection)
1560
+ return __ractors_original_tag_content(connection) if Ractor.main?
1561
+ snap = ::RactorRailsShim::QUERY_LOGS_SNAPSHOT
1562
+ return __ractors_original_tag_content(connection) unless snap
1563
+ format = snap[:format]
1564
+ formatter = case format
1565
+ when :sqlcommenter then ::ActiveRecord::QueryLogs::SQLCommenter
1566
+ else ::ActiveRecord::QueryLogs::LegacyFormatter
1567
+ end
1568
+ return nil unless formatter
1569
+ context = ActiveSupport::ExecutionContext.to_h
1570
+ context[:connection] ||= connection
1571
+ pairs = snap[:handlers].filter_map do |(key, kind, data)|
1572
+ val = case kind
1573
+ when :get_key then context[key]
1574
+ when :identity then data
1575
+ else nil
1576
+ end
1577
+ formatter.format(key, val) unless val.nil?
1578
+ end
1579
+ formatter.join(pairs)
1580
+ end
1581
+ RUBY
1582
+ end
1583
+
1584
+ # Capture the QueryLogs handlers/formatter as a shareable snapshot for
1585
+ # workers (called post-boot, main Ractor, in prepare_for_ractors!).
1586
+ #
1587
+ # We deliberately do NOT use Ractor.make_shareable on the raw `@handlers`
1588
+ # objects: a handler may be a ZeroArityHandler wrapping a Proc, a raw
1589
+ # lambda/Proc tag, or an IdentityHandler whose value is unshareable — all of
1590
+ # which make_shareable raises on, which (the original rescue swallowed) left
1591
+ # QUERY_LOGS_SNAPSHOT unset and every worker falling through to the original
1592
+ # tag_content -> @handlers read -> Ractor::IsolationError. Instead we build a
1593
+ # fresh, guaranteed-shareable structure:
1594
+ # { format: :legacy|:sqlcommenter,
1595
+ # handlers: [[key, :get_key, nil] | [key, :identity, value], ...] }
1596
+ # Only GetKeyHandler (context key lookup) and IdentityHandler (constant
1597
+ # value, when that value itself is shareable) are captured. Proc/lambda
1598
+ # handlers can't be expressed cross-Ractor and are dropped in workers (tags
1599
+ # that depend on per-request Procs simply don't appear in worker query
1600
+ # comments — acceptable; the main Ractor still logs them).
1601
+ def snapshot_query_logs!
1602
+ return unless defined?(::ActiveRecord::QueryLogs)
1603
+ return if RactorRailsShim.const_defined?(:QUERY_LOGS_SNAPSHOT)
1604
+ return unless Ractor.main?
1605
+ begin
1606
+ ql = ::ActiveRecord::QueryLogs
1607
+ format = ql.tags_formatter
1608
+ format = :legacy if format == false || format.nil?
1609
+ raw_handlers = ql.instance_variable_get(:@handlers) || []
1610
+ entries = []
1611
+ raw_handlers.each do |key, handler|
1612
+ if handler.is_a?(::ActiveRecord::QueryLogs::GetKeyHandler)
1613
+ entries << [key, :get_key, nil]
1614
+ elsif handler.is_a?(::ActiveRecord::QueryLogs::IdentityHandler)
1615
+ value = handler.instance_variable_get(:@value)
1616
+ next unless Ractor.shareable?(value)
1617
+ entries << [key, :identity, value]
1618
+ else
1619
+ # ZeroArityHandler (wraps a Proc) or a raw Proc/lambda tag:
1620
+ # intrinsically unshareable / depends on a closure; skip in workers.
1621
+ next
1622
+ end
1623
+ end
1624
+ snap = { format: format, handlers: entries.freeze }.freeze
1625
+ Ractor.make_shareable(snap)
1626
+ RactorRailsShim.const_set(:QUERY_LOGS_SNAPSHOT, snap)
1627
+ rescue StandardError
1628
+ nil
1629
+ end
1630
+ end
1631
+
1632
+ # Patch ActiveRecord::Migrator.migrations_paths (a singleton attr_accessor
1633
+ # reading the `@migrations_paths` class ivar) and
1634
+ # ActiveRecord::Migration::CheckPending (the dev pending-migration
1635
+ # middleware) to be Ractor-safe. In dev, CheckPending runs on every request
1636
+ # and reads Migrator.migrations_paths + mutates its own `@watcher` /
1637
+ # `@needs_check` ivars on the (frozen, shared) middleware instance. Route
1638
+ # the class ivar and the instance ivars through IsolatedExecutionState so
1639
+ # each worker reads the main Ractor's migrations paths and builds its own
1640
+ # watcher. This keeps the dev pending-migration guard working under kino
1641
+ # :ractor instead of stripping the middleware.
1642
+ def _install_activerecord_migration_patch
1643
+ return if @activerecord_migration_patched
1644
+ @activerecord_migration_patched = true
1645
+ _register_patch :activerecord_migration, "8.1"
1646
+ return unless defined?(::ActiveRecord::Migrator)
1647
+
1648
+ mig = ::ActiveRecord::Migrator
1649
+ mp_key = :ractor_rails_shim_migrator_migrations_paths
1650
+ mp_key_str = mp_key.inspect
1651
+ mig.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
1652
+ def migrations_paths
1653
+ v = ActiveSupport::IsolatedExecutionState[#{mp_key_str}]
1654
+ return v unless v.nil?
1655
+ if Ractor.main? && instance_variable_defined?(:@migrations_paths)
1656
+ v = @migrations_paths
1657
+ ActiveSupport::IsolatedExecutionState[#{mp_key_str}] = v
1658
+ v
1659
+ else
1660
+ ["db/migrate"].freeze
1661
+ end
1662
+ end
1663
+ def migrations_paths=(val)
1664
+ ActiveSupport::IsolatedExecutionState[#{mp_key_str}] = val
1665
+ @migrations_paths = val if Ractor.main?
1666
+ val
1667
+ end
1668
+ RUBY
1669
+
1670
+ return unless defined?(::ActiveRecord::Migration::CheckPending)
1671
+ cp = ::ActiveRecord::Migration::CheckPending
1672
+ w_key = :ractor_rails_shim_check_pending_watcher
1673
+ nc_key = :ractor_rails_shim_check_pending_needs_check
1674
+ w_key_str = w_key.inspect
1675
+ nc_key_str = nc_key.inspect
1676
+ cp.class_eval <<-RUBY, __FILE__, __LINE__ + 1
1677
+ def call(env)
1678
+ @mutex.synchronize do
1679
+ watcher = ActiveSupport::IsolatedExecutionState[#{w_key_str}]
1680
+ if watcher.nil?
1681
+ watcher = ActiveSupport::IsolatedExecutionState[#{w_key_str}] = build_watcher do
1682
+ ActiveSupport::IsolatedExecutionState[#{nc_key_str}] = true
1683
+ ::ActiveRecord::Migration.check_pending_migrations
1684
+ ActiveSupport::IsolatedExecutionState[#{nc_key_str}] = false
1685
+ end
1686
+ end
1687
+ needs_check = ActiveSupport::IsolatedExecutionState[#{nc_key_str}]
1688
+ needs_check = true if needs_check.nil?
1689
+ if needs_check
1690
+ watcher.execute
1691
+ else
1692
+ watcher.execute_if_updated
1693
+ end
1694
+ end
1695
+ @app.call(env)
1696
+ end
1697
+ RUBY
1698
+ end
1699
+
1700
+ # In the shared :ractor graph, ActiveRecord model classes can end up with a
1701
+ # nil `__callbacks` (the class_attribute value can't be made shareable when
1702
+ # it holds unshareable callback Procs, so the shim's shareable fallback
1703
+ # returns nil). `has_transactional_callbacks?` calls the generated
1704
+ # `_rollback_callbacks` / `_commit_callbacks` / `_before_commit_callbacks`
1705
+ # readers, which do `__callbacks[:kind]` directly — bypassing the
1706
+ # `run_callbacks_with_nil_safe` guard. With a nil `__callbacks` that raises
1707
+ # `NoMethodError: undefined method '[]' for nil`, breaking every
1708
+ # `save` / `update` / `destroy` (they run inside a transaction). Guard it:
1709
+ # a nil / empty callback table means no transactional callbacks.
1710
+ def _install_activerecord_transaction_callbacks_patch
1711
+ return if @activerecord_transaction_callbacks_patched
1712
+ @activerecord_transaction_callbacks_patched = true
1713
+ _register_patch :activerecord_transaction_callbacks, "8.1"
1714
+ return unless defined?(::ActiveRecord::Base)
1715
+ ar = ::ActiveRecord::Base
1716
+ ar.class_eval <<-RUBY, __FILE__, __LINE__ + 1
1717
+ def has_transactional_callbacks?
1718
+ cb = __callbacks
1719
+ return false unless cb
1720
+ !((cb[:rollback] || []).empty?) ||
1721
+ !((cb[:commit] || []).empty?) ||
1722
+ !((cb[:before_commit] || []).empty?)
1723
+ end
1724
+ RUBY
1725
+ end
1726
+ end
1727
+ end