Module: RactorRailsShim::FallbackBuilder

Extended by:
RoleDefaults
Defined in:
lib/ractor_rails_shim/roles/fallback_builder.rb

Defined Under Namespace

Modules: ShareableCopy, ValueLookup

Class Method Summary collapse

Methods included from RoleDefaults

default_funnel, default_reassign_shareable_const, default_safe_const_get

Class Method Details

.build!Object

Build the shareable fallback for every class_attribute / mattr_accessor value the shim has rerouted. For each registered attribute we:

1. Read the main-ractor value (from its IES slot, which `redefine`
 seeded at class_attribute-definition time).
2. Make it shareable (deep-freeze + callable-replacement for any Procs
 it holds — same technique as make_app_shareable!, applied to the
 config sub-graph).
3. Store under the IES key in a frozen Hash on RactorRailsShim, which
 is readable from every Ractor (it's a constant).

Workers' class_attribute readers fall back to this when their own IES slot is nil. Must run in the main Ractor. Idempotent (guarded by



220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
# File 'lib/ractor_rails_shim/roles/fallback_builder.rb', line 220

def self.build!
  return nil if @built
  @built = true

  fallback = {}
  chain = value_lookup_chain
  lookup_ctx = {
    storage: storage,
    class_attr_values: class_attr_values,
    main: ::Ractor.main?,
    safe_const_get: safe_const_get,
  }
  class_attributes.each do |(owner_name, attr_name, ies_key, default_val)|
    # Skip the Rails logger — it's intrinsically unshareable (IO + Mutex +
    # mutable formatter) and workers build their own per-Ractor logger
    # via the patched reader. Trying to make it shareable would freeze the
    # IO, breaking logging in main too.
    next if owner_name == "Rails" && attr_name == :logger

    # Walk the ValueLookup chain to resolve the value. The first
    # non-nil result wins; if all links return nil, val stays nil.
    val = nil
    chain.each do |link|
      val = link.lookup(owner_name, attr_name, ies_key, **lookup_ctx)
      break unless val.nil?
    end

    shareable_val = nil
    # Try the live value first.
    if !val.nil?
      shareable_val = try_make_shareable(val, owner_name, attr_name)
    end
    # If the live value couldn't be shared (e.g. __callbacks holds
    # self-capturing Procs), fall back to the definition-time default.
    # For a frozen shared app this is correct: boot-time callbacks
    # already ran in main; workers treat them as already-run
    # (empty/no-op). The default is dup'd if it's a mutable container
    # (Hash/Array) so each entry in the fallback is an independent
    # shareable copy.
    if shareable_val.nil? && !default_val.nil?
      shareable_val = try_make_shareable(shareable_copy(default_val), owner_name, attr_name, default: true)
    end

    fallback[ies_key] = shareable_val if shareable_val
  end

  # The class_attributes loop above only seeds the declaring-module's
  # value (keyed by its per-owner key). Per-subclass overrides written
  # through the class_attribute writer land in CLASS_ATTR_VALUES under
  # their OWN per-owner keys (e.g. Api::PostsController's
  # `mimes_for_respond_to = {json: {}}`). Workers resolve values via an
  # ancestor walk keyed by each class's per-owner key, so the frozen
  # SHAREABLE_FALLBACK must contain those per-subclass entries too.
  class_attr_values.each do |kkey, val|
    next if kkey.to_s.include?("logger")
    attr_name = kkey.to_s.sub(/\Aractor_rails_shim_class_attr_\d+_/, "").to_sym
    shareable_val = try_make_shareable(val, nil, attr_name)
    fallback[kkey] = shareable_val if shareable_val
  end

  fallback.freeze
  Ractor.make_shareable(fallback)

  # Make the shareable mattr-defaults subset shareable too (workers
  # read it via the constant). Frozen + reassigned via the centralized
  # helper.
  shareable_mattr_defaults.freeze
  Ractor.make_shareable(shareable_mattr_defaults)

  reassign_shareable_const.call(:SHAREABLE_FALLBACK, fallback)
  reassign_shareable_const.call(:SHAREABLE_MATTR_DEFAULTS, shareable_mattr_defaults)
  fallback
end

.built?Boolean

Has build! run? Lives on FallbackBuilder (Issue #24 — own your own state), NOT on the facade singleton.

Returns:

  • (Boolean)


199
200
201
# File 'lib/ractor_rails_shim/roles/fallback_builder.rb', line 199

def self.built?
  @built
end

.class_attr_valuesObject



106
107
108
# File 'lib/ractor_rails_shim/roles/fallback_builder.rb', line 106

def self.class_attr_values
  @class_attr_values || RactorRailsShim::Registry.class_attr_values
end

.class_attributesObject



102
103
104
# File 'lib/ractor_rails_shim/roles/fallback_builder.rb', line 102

def self.class_attributes
  @class_attributes || RactorRailsShim::Registry.class_attributes
end

.configure(safe_const_get: nil, replace_unshareable_procs: nil, replace_locks_and_concurrent_maps: nil, reassign_shareable_const: nil, class_attributes: nil, class_attr_values: nil, shareable_mattr_defaults: nil, storage: nil, value_lookup_chain: nil) ⇒ Object

Inject the callable + registry collaborators. The callables: safe_const_get(path) → value|nil; replace_unshareable_procs(val) and replace_locks_and_concurrent_maps(val) mutate val in place; reassign_shareable_const(sym, value) reassigns the shareable constant. The registries: class_attributes (CLASS_ATTRIBUTES array), class_attr_values (CLASS_ATTR_VALUES store), shareable_mattr_defaults (SHAREABLE_MATTR_DEFAULTS array), storage (IES storage, hash-like: storage). The lookup chain: value_lookup_chain (Array of lookup modules, each responding to .lookup(owner, attr_name, ies_key, **ctx) -> val|nil). Passing nil for any (or calling reset_configuration) restores the facade-lookup default for that collaborator.



57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
# File 'lib/ractor_rails_shim/roles/fallback_builder.rb', line 57

def self.configure(safe_const_get: nil, replace_unshareable_procs: nil,
                   replace_locks_and_concurrent_maps: nil,
                   reassign_shareable_const: nil, class_attributes: nil,
                   class_attr_values: nil, shareable_mattr_defaults: nil,
                   storage: nil, value_lookup_chain: nil)
  @safe_const_get = safe_const_get
  @replace_unshareable_procs = replace_unshareable_procs
  @replace_locks_and_concurrent_maps = replace_locks_and_concurrent_maps
  @reassign_shareable_const = reassign_shareable_const
  @class_attributes = class_attributes
  @class_attr_values = class_attr_values
  @shareable_mattr_defaults = shareable_mattr_defaults
  @storage = storage
  @value_lookup_chain = value_lookup_chain
end

.reassign_shareable_constObject



98
99
100
# File 'lib/ractor_rails_shim/roles/fallback_builder.rb', line 98

def self.reassign_shareable_const
  @reassign_shareable_const || RactorRailsShim.method(:_reassign_shareable_const)
end

.replace_locks_and_concurrent_mapsObject



94
95
96
# File 'lib/ractor_rails_shim/roles/fallback_builder.rb', line 94

def self.replace_locks_and_concurrent_maps
  @replace_locks_and_concurrent_maps || RactorRailsShim::ShareabilityTraversal.method(:replace_locks_and_concurrent_maps!)
end

.replace_unshareable_procsObject



90
91
92
# File 'lib/ractor_rails_shim/roles/fallback_builder.rb', line 90

def self.replace_unshareable_procs
  @replace_unshareable_procs || RactorRailsShim::ShareabilityTraversal.method(:replace_unshareable_procs!)
end

.reset_built!Object

Clear the built flag. Test seam + reinstall seam.



204
205
206
# File 'lib/ractor_rails_shim/roles/fallback_builder.rb', line 204

def self.reset_built!
  @built = false
end

.reset_configurationObject

Restore the default (facade-lookup) collaborators. Test seam.



74
75
76
77
78
79
80
81
82
83
84
# File 'lib/ractor_rails_shim/roles/fallback_builder.rb', line 74

def self.reset_configuration
  @safe_const_get = nil
  @replace_unshareable_procs = nil
  @replace_locks_and_concurrent_maps = nil
  @reassign_shareable_const = nil
  @class_attributes = nil
  @class_attr_values = nil
  @shareable_mattr_defaults = nil
  @storage = nil
  @value_lookup_chain = nil
end

.safe_const_getObject



86
87
88
# File 'lib/ractor_rails_shim/roles/fallback_builder.rb', line 86

def self.safe_const_get
  @safe_const_get || default_safe_const_get
end

.shareable_copy(val) ⇒ Object

Return a fresh copy of a mutable default container (Hash/Array) so the fallback entry is independent. Frozen/shareable defaults pass through. Uses the ShareableCopy dispatch table (Issue #40, POODR §5b).



347
348
349
# File 'lib/ractor_rails_shim/roles/fallback_builder.rb', line 347

def self.shareable_copy(val)
  ShareableCopy.call(val)
end

.shareable_mattr_defaultsObject



110
111
112
# File 'lib/ractor_rails_shim/roles/fallback_builder.rb', line 110

def self.shareable_mattr_defaults
  @shareable_mattr_defaults || RactorRailsShim::Registry.shareable_mattr_defaults
end

.storageObject



114
115
116
# File 'lib/ractor_rails_shim/roles/fallback_builder.rb', line 114

def self.storage
  @storage || RactorRailsShim.storage
end

.try_make_shareable(val, owner_name, attr_name, default: false) ⇒ Object

Best-effort attempt to make val shareable (callable-replacement for Procs + lock-replacement + make_shareable). Returns the shareable val, or nil if it can't be made shareable. On failure, emits a warning (unless default: — defaults are expected to sometimes be unshareable, so we skip the noise).



299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
# File 'lib/ractor_rails_shim/roles/fallback_builder.rb', line 299

def self.try_make_shareable(val, owner_name, attr_name, default: false)
  # __callbacks and validators hold callback chains / validator
  # instances with self-capturing Procs that can NEVER be made
  # shareable. This is expected: workers correctly treat callbacks as
  # already-run (the nil-safe run_callbacks patch yields the block
  # directly). Skip the attempt entirely — don't waste cycles traversing
  # the graph, and don't emit warnings for known-expected failures.
  attr_sym = attr_name.to_s
  return nil if attr_sym.end_with?("__callbacks") ||
                attr_sym.end_with?("__validators") ||
                attr_sym.end_with?("default_connection_handler") ||
                attr_sym.include?("default_connection_handler") ||
                attr_sym.end_with?("__class_attr__queue_adapter")

  # Try a plain deep-freeze FIRST. Several class attributes (notably
  # ActiveRecord's `_reflections`, a Hash of Reflection objects) ARE
  # genuinely shareable as-is — their Procs are self-contained — but the
  # proc/lock-replacement traversal that runs first in the old order
  # mutates the value in a way that then defeats Ractor.make_shareable,
  # so the attribute fell back to nil in workers and broke associations
  # (`_reflect_on_association` -> `nil[]`). A plain make_shareable
  # succeeds for these. Only fall back to the (slower) replacement
  # traversal when the plain attempt raises — that path is still required
  # for values whose Procs capture unshareable state (it swaps them for
  # shareable callables). Either order produces a correct shareable
  # value; plain-first just avoids the spurious failure.
  begin
    Ractor.make_shareable(val)
    val
  rescue StandardError => e
    begin
      replace_unshareable_procs.call(val)
      replace_locks_and_concurrent_maps.call(val)
      Ractor.make_shareable(val)
      val
    rescue StandardError => e2
      unless default
        warn "ractor-rails-shim: could not make attribute " \
             "#{owner_name}##{attr_name} shareable (#{e2.class}: #{e2.message[0,80]}); workers will fall back to default or nil"
      end
      nil
    end
  end
end

.value_lookup_chainObject



118
119
120
# File 'lib/ractor_rails_shim/roles/fallback_builder.rb', line 118

def self.value_lookup_chain
  @value_lookup_chain || ValueLookup::DEFAULT_CHAIN
end