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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +101 -0
- data/LICENSE.txt +21 -0
- data/README.md +265 -0
- data/exe/ractor-rails-check +39 -0
- data/lib/ractor_rails_shim/check.rb +190 -0
- data/lib/ractor_rails_shim/fallback_ies.rb +33 -0
- data/lib/ractor_rails_shim/patches/action_controller.rb +301 -0
- data/lib/ractor_rails_shim/patches/action_dispatch.rb +851 -0
- data/lib/ractor_rails_shim/patches/action_view.rb +582 -0
- data/lib/ractor_rails_shim/patches/active_model_attribute.rb +97 -0
- data/lib/ractor_rails_shim/patches/active_record_model_schema.rb +58 -0
- data/lib/ractor_rails_shim/patches/active_support.rb +1085 -0
- data/lib/ractor_rails_shim/patches/activerecord.rb +1727 -0
- data/lib/ractor_rails_shim/patches/class_attribute.rb +194 -0
- data/lib/ractor_rails_shim/patches/core.rb +806 -0
- data/lib/ractor_rails_shim/patches/devise.rb +172 -0
- data/lib/ractor_rails_shim/patches/execution_wrapper.rb +76 -0
- data/lib/ractor_rails_shim/patches/kaminari.rb +82 -0
- data/lib/ractor_rails_shim/patches/make_shareable.rb +853 -0
- data/lib/ractor_rails_shim/patches/mattr_accessor.rb +162 -0
- data/lib/ractor_rails_shim/patches/openssl.rb +58 -0
- data/lib/ractor_rails_shim/patches/orm_adapter.rb +32 -0
- data/lib/ractor_rails_shim/patches/polymorphic_routes.rb +115 -0
- data/lib/ractor_rails_shim/patches/propshaft.rb +110 -0
- data/lib/ractor_rails_shim/patches/rack.rb +160 -0
- data/lib/ractor_rails_shim/patches/rails_module.rb +192 -0
- data/lib/ractor_rails_shim/patches/route_helpers.rb +119 -0
- data/lib/ractor_rails_shim/patches/rubygems.rb +65 -0
- data/lib/ractor_rails_shim/patches/url_helpers.rb +78 -0
- data/lib/ractor_rails_shim/patches/warden.rb +195 -0
- data/lib/ractor_rails_shim/patches/zeitwerk_registry.rb +124 -0
- data/lib/ractor_rails_shim/patches.rb +67 -0
- data/lib/ractor_rails_shim/version.rb +5 -0
- data/lib/ractor_rails_shim/version_check.rb +88 -0
- data/lib/ractor_rails_shim.rb +33 -0
- 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
|