ractor-rails-shim 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +83 -0
  3. data/lib/ractor_rails_shim/callbacks/dependent_association_transport.rb +80 -0
  4. data/lib/ractor_rails_shim/callbacks/registry.rb +71 -0
  5. data/lib/ractor_rails_shim/callbacks/symbolic_transport.rb +190 -0
  6. data/lib/ractor_rails_shim/callbacks.rb +61 -0
  7. data/lib/ractor_rails_shim/foundation/storage_strategy.rb +190 -71
  8. data/lib/ractor_rails_shim/loader.rb +8 -0
  9. data/lib/ractor_rails_shim/patches/action_controller.rb +179 -0
  10. data/lib/ractor_rails_shim/patches/action_dispatch.rb +109 -0
  11. data/lib/ractor_rails_shim/patches/action_view.rb +161 -3
  12. data/lib/ractor_rails_shim/patches/active_model_attribute.rb +63 -0
  13. data/lib/ractor_rails_shim/patches/active_record_model_schema.rb +61 -0
  14. data/lib/ractor_rails_shim/patches/active_record_store.rb +169 -0
  15. data/lib/ractor_rails_shim/patches/active_storage.rb +490 -0
  16. data/lib/ractor_rails_shim/patches/active_support.rb +13 -6
  17. data/lib/ractor_rails_shim/patches/activerecord.rb +820 -17
  18. data/lib/ractor_rails_shim/patches/activerecord_reflection.rb +317 -0
  19. data/lib/ractor_rails_shim/patches/class_attribute.rb +33 -11
  20. data/lib/ractor_rails_shim/patches/core.rb +15 -0
  21. data/lib/ractor_rails_shim/patches/devise.rb +38 -0
  22. data/lib/ractor_rails_shim/patches/kaminari.rb +35 -0
  23. data/lib/ractor_rails_shim/patches/mail.rb +502 -0
  24. data/lib/ractor_rails_shim/patches/marcel.rb +191 -0
  25. data/lib/ractor_rails_shim/patches/mattr_accessor.rb +73 -2
  26. data/lib/ractor_rails_shim/patches/route_helpers.rb +10 -1
  27. data/lib/ractor_rails_shim/roles/app_shareabilizer.rb +13 -0
  28. data/lib/ractor_rails_shim/roles/callback_capture.rb +40 -16
  29. data/lib/ractor_rails_shim/roles/fallback_builder.rb +42 -8
  30. data/lib/ractor_rails_shim/roles/install_strategy.rb +32 -0
  31. data/lib/ractor_rails_shim/roles/lifecycle.rb +2 -0
  32. data/lib/ractor_rails_shim/roles/shareability_traversal.rb +42 -0
  33. data/lib/ractor_rails_shim/version.rb +1 -1
  34. metadata +10 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6ebcbda319623aa7003caf170774074e19fdf2d6452ed086fbc5edbfec037fa5
4
- data.tar.gz: 7ab60400652444ccbc5ca39ac403d387c156cc8a6178700f10c9d744db9c3fad
3
+ metadata.gz: c9a5a31eef9fc564a38af828fa80c55909878e502952358a129760441b36844b
4
+ data.tar.gz: ed5bee3ef13dd02d95ab06e806cebc2a1248da438e6f82133654419a85485c28
5
5
  SHA512:
6
- metadata.gz: 69433fe039a2e2bea0302e13117a0ba701b786eaaec6e2e636d1d4786d74428e11701db2c703bc6fdb10c698febb6d5bce646adba24011513802d76de237349f
7
- data.tar.gz: 615e99053521f65e7aa013d8a5c23e97991bd8b3f8593a92c9c2cf57e2ab245796bde664731ac943fe6b52681a8416c4bddac9635a7c5be6b2c3cc6339ecbdd2
6
+ metadata.gz: c77e93d1676466dd59164e597f888837df387669a8579e0e3f5d2056fab47e4a9b7bcca7502993f932fdffc7e906b933a9724a71ab167bfb835680e083dba3d6
7
+ data.tar.gz: 5822e8b0c5cb9c9ab204b9b052cb1501a1ecb4bf9a3d8737881095771835d8be5b7441f5d5d665ef1bb6781d422240a0135bc56079f02e5d08823bb8a8142c46
data/CHANGELOG.md CHANGED
@@ -7,6 +7,89 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.0]
11
+
12
+ ### Added — Rails feature support for worker Ractors
13
+ - **`render json:` / `render xml:` / `render js:` in worker Ractors.**
14
+ ActionController::Renderers defines `_render_with_renderer_*` via
15
+ `define_method(&block)` at boot — the block is compiled in the main Ractor
16
+ and can't be called from a worker. Redefined all three as string-eval'd
17
+ `def`s (no captured binding) callable from any Ractor.
18
+ (`_install_json_renderer_patch`, `patches/action_controller.rb`)
19
+
20
+ - **ActionMailer `deliver_now` from a worker Ractor.** The full build +
21
+ ERB render + deliver path works in a worker Ractor. Patches the `mail`
22
+ gem (raw `@@cvar`s routed through per-Ractor IES; `Mail::Configuration`
23
+ per-worker; `Mail::Parsers::*Parser` + `Mail::Utilities` module ivars
24
+ captured into a shareable constant with string-eval `def` readers;
25
+ `Mail::TestMailer.deliveries` per-Ractor; `Mail::PartsList` /
26
+ `AttachmentsList` `DelegateClass` delegating methods redefined as
27
+ shareable `def`s). Patches `ActionMailer::Base` (`mailer_name` callers
28
+ reimplemented via string-eval; `PROTECTED_IVARS` made shareable; `config`
29
+ falls back to empty `OrderedOptions` when `class_attribute` resolves nil;
30
+ `local_prefixes` overridden for mailers). SMTP delivery also viable
31
+ (`Mail::SMTP::DEFAULTS` made shareable). (`patches/mail.rb`)
32
+
33
+ - **ActiveStorage `has_one_attached` in a worker Ractor.** Blob creation,
34
+ file upload, attachment persistence, and cross-worker read-back all work.
35
+ 16 patched layers:
36
+ - `has_one_attached` / `has_many_attached` scope lambda built with a
37
+ shareable `self` so `User.reflections` is Ractor-shareable.
38
+ - `Blob.build_after_unfurling` / `compute_checksum_in_chunks` redefined
39
+ without `tap` blocks.
40
+ - `Blob#service_name` falls back to `self.class.service&.name`.
41
+ - `Blob.type_for_attribute(:metadata)` / `attribute_types` / per-worker
42
+ `_default_attributes` force `Type::Serialized` with
43
+ `IndifferentCoder(JSON)` for `:metadata`.
44
+ - `generated_attribute_methods` modules captured in
45
+ `SHAREABLE_GEN_ATTR_METHODS` at prepare time; workers read via per-Ractor
46
+ IES instead of creating an empty Module.
47
+ - `ActiveRecord::Store#store_accessor` redefined as string-eval `def`s;
48
+ `store_accessor_for` coder resolution block-free.
49
+ - `ThroughReflection#source_reflection_name` respects `options[:source]`.
50
+ - `ThroughReflection#check_validity!` uses per-worker cache (was writing
51
+ `@validated` on frozen reflection → FrozenError).
52
+ - `full_table_name_prefix` / `full_table_name_suffix` reimplemented
53
+ without un-shareable `module_parents.detect { }` blocks.
54
+ - `attribute_names` aliased to `_rrs_orig_attribute_names` (was `super`
55
+ with no superclass method); `define_attribute_methods` calls original
56
+ in main (was no-op everywhere).
57
+ - `SecureRandom::BASE36_ALPHABET` / `BASE58_ALPHABET` deep-frozen at
58
+ `prepare_for_ractors!` time.
59
+ - `ActiveStorage.table_name_prefix` / `table_name_suffix` redefined as
60
+ shareable string-eval `def`s; `_seed_active_storage_prefix!` seeds
61
+ constants and resets `Blob`/`Attachment` `table_name` in main.
62
+ - `Marcel::MimeType` / `Marcel::Magic` block-based methods redefined as
63
+ string-eval `def`s; lookup tables frozen.
64
+ - `ActiveStorage::Service::Registry#fetch` works via `class_attribute`
65
+ fallback.
66
+ - `Devise.mailer` `@@mailer_ref` cvar captured as shareable constant;
67
+ workers read the captured copy.
68
+ - Callback `if:` / `unless:` Symbol conditions captured and checked via
69
+ `condition_allows?` in SymbolicTransport.
70
+ (`patches/active_storage.rb`, `patches/active_record_store.rb`,
71
+ `patches/marcel.rb`, `patches/active_model_attribute.rb`,
72
+ `patches/activerecord.rb`, `patches/activerecord_reflection.rb`,
73
+ `patches/devise.rb`, `callbacks/symbolic_transport.rb`,
74
+ `roles/callback_capture.rb`, `roles/shareability_traversal.rb`)
75
+
76
+ - **`FEATURES.md` — feature support matrix.** Documents every supported /
77
+ unsupported / unverified feature for worker Ractor mode, with known
78
+ limitations (around callbacks, sanitize/Nokogiri, ActiveJob
79
+ `perform_later`, block `after_save` workaround, GlobalID
80
+ `after_create_commit` workaround).
81
+
82
+ ### Known limitations
83
+ - `around_action` / `:around` callbacks — not transported (must wrap yield).
84
+ - `sanitize` / `simple_format` (Nokogiri) — ractor-unsafe C extension.
85
+ - ActiveJob `perform_later` / `deliver_later` — `queue_adapter`
86
+ `class_attribute` resolves nil in workers; `GlobalID.app` class ivar not
87
+ captured by the shim (nil in workers). **TODO #5**.
88
+ - `has_one_attached` block `after_save` callback — lambda can't cross
89
+ Ractor boundary. Probe uses `blob.unfurl` + `insert!` workaround.
90
+ - `ActiveStorage::Attachment` `after_create_commit` callbacks — enqueue
91
+ ActiveJobs via GlobalID (`@app` nil in workers). Probe uses `insert!`.
92
+
10
93
  ## [0.3.0]
11
94
 
12
95
  ### Changed — breaking (public API)
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Callbacks::DependentAssociationTransport — replays a model's `dependent:`
4
+ # association cascades on the `:destroy` kind. This is the reference transport
5
+ # for a callback that is a LAMBDA (the `dependent:` option registers an
6
+ # unshareable `before_destroy` lambda) but whose *effect* reduces to a
7
+ # shareable, declarative spec: (class_name => [{name:, type:, macro:}]).
8
+ #
9
+ # On `:destroy`, for each captured dependent association the transport calls
10
+ # `record.association(name).handle_dependency` — the exact method the original
11
+ # lambda invoked — so `:destroy` / `:delete` / `:nullify` / `:restrict_*` all
12
+ # dispatch correctly (that dispatch lives in AR's handle_dependency).
13
+ #
14
+ # Source shape (frozen into SHAREABLE_DEPENDENT_ASSOCIATIONS):
15
+ # { class_name(String) => [ {name: Symbol, type: Symbol, macro: Symbol}, … ] }
16
+ #
17
+ # Keyed by class *name* (not object_id) because `dependent:` replay looks up the
18
+ # record's own class, not its ancestors (a class only cascades the associations
19
+ # it itself declared).
20
+
21
+ module RactorRailsShim
22
+ module Callbacks
23
+ class DependentAssociationTransport
24
+ # source: a Hash { class_name => [entry, …] } as described above, OR a
25
+ # callable returning that Hash (resolves the shareable constant lazily at
26
+ # replay time in a worker).
27
+ def initialize(source:)
28
+ @source = source
29
+ end
30
+
31
+ def source
32
+ s = @source
33
+ return s.call if s.respond_to?(:call)
34
+ return ::RactorRailsShim.const_get(s) if s.is_a?(::Symbol)
35
+ s
36
+ rescue StandardError
37
+ nil
38
+ end
39
+
40
+ # This transport ONLY owns the `:destroy` kind. (The symbolic transport
41
+ # also applies to :destroy for app before_destroy methods; both run.)
42
+ def applies_to?(kind)
43
+ kind == :destroy
44
+ end
45
+
46
+ # Re-drive every dependent association the record's class declared,
47
+ # BEFORE the record itself is deleted (matching the before_destroy order).
48
+ def before(context, kind)
49
+ return unless kind == :destroy
50
+ table = source
51
+ entries = table && table[class_name_of(context)]
52
+ return unless entries
53
+ entries.each do |entry|
54
+ assoc = association_of(context, entry[:name])
55
+ assoc.handle_dependency if assoc && assoc.respond_to?(:handle_dependency)
56
+ end
57
+ end
58
+
59
+ # Dependent cascades are a before_destroy concern; nothing runs after.
60
+ def after(_context, _kind)
61
+ nil
62
+ end
63
+
64
+ private
65
+
66
+ def class_name_of(context)
67
+ klass = context.class
68
+ klass.name if klass.respond_to?(:name)
69
+ rescue StandardError
70
+ nil
71
+ end
72
+
73
+ def association_of(context, name)
74
+ context.association(name) if context.respond_to?(:association)
75
+ rescue StandardError
76
+ nil
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Callbacks::Registry — the coordinator that dispatches callback replay across
4
+ # an open set of {Callbacks::Transport} objects (POODR §3 duck-typing, §5
5
+ # message-based design, Open/Closed: add a callback *kind* by registering a new
6
+ # transport, not by editing a `case kind` branch).
7
+ #
8
+ # The Registry owns the single `yield` of the real callback-chain body. Each
9
+ # applicable transport runs its `before` work, then the block runs once, then
10
+ # each runs its `after` work. This lets two transports legitimately share a kind
11
+ # (e.g. on `:destroy` both the symbolic `before_destroy` filters AND the
12
+ # `dependent:` cascade run) without any transport owning the block.
13
+ #
14
+ # A transport duck-types to:
15
+ # applies_to?(kind) -> bool
16
+ # before(context, kind) -> runs before-filter work for that chain kind (no-op ok)
17
+ # after(context, kind) -> runs after-filter work for that chain kind (no-op ok)
18
+ # install -> hook into the framework to begin capturing (optional)
19
+ # capture -> finalize the shareable snapshot (optional)
20
+ #
21
+ # Both `install` and `capture` are optional; the Registry forwards them and
22
+ # ignores any transport that does not respond.
23
+
24
+ module RactorRailsShim
25
+ module Callbacks
26
+ class Registry
27
+ # transports: an Enumerable of transport objects (duck-typed). Defaults
28
+ # to empty so callers can `register` incrementally.
29
+ def initialize(transports = [])
30
+ @transports = transports.is_a?(Enumerable) ? transports.to_a : Array(transports)
31
+ end
32
+
33
+ # Register one transport. Returns self for chaining.
34
+ def register(transport)
35
+ @transports << transport
36
+ self
37
+ end
38
+
39
+ # The subset of transports that apply to `kind`. Public so tests (and a
40
+ # future "is anything replayable for this kind?" check) can introspect.
41
+ def applicable(kind)
42
+ @transports.select { |t| t.applies_to?(kind) }
43
+ end
44
+
45
+ # Replay the empty callback chain for `context` and `kind`. Runs every
46
+ # applicable transport's before-work, yields the block ONCE, then runs
47
+ # every applicable transport's after-work. Returns the block's result.
48
+ # If nothing applies, just yields (matching the original empty-chain path).
49
+ def replay(context, kind, &block)
50
+ applicable = applicable(kind)
51
+ return yield if applicable.empty?
52
+ applicable.each { |t| t.before(context, kind) }
53
+ result = yield
54
+ applicable.each { |t| t.after(context, kind) }
55
+ result
56
+ end
57
+
58
+ # Forward the optional framework-hooking phase to every transport that
59
+ # responds. Idempotent install is each transport's own concern.
60
+ def install
61
+ @transports.each { |t| t.install if t.respond_to?(:install) }
62
+ end
63
+
64
+ # Forward the optional finalize-the-snapshot phase to every transport
65
+ # that responds. Called from the main Ractor at prepare time.
66
+ def capture
67
+ @transports.each { |t| t.capture if t.respond_to?(:capture) }
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ # Callbacks::SymbolicTransport — replays captured SYMBOLIC callback filters
6
+ # (method-name filters such as `before_save :normalize_title`) for ANY callback
7
+ # kind. This is the generic half of "a solution for any callback": a callback
8
+ # declared with a Symbol is shareable (the Symbol crosses the Ractor boundary),
9
+ # so we capture it at declaration time and re-invoke the method in the worker.
10
+ #
11
+ # Generalizes the controller-only `:process_action` replay to every kind
12
+ # (`:save`, `:create`, `:update`, `:destroy`, `:validation`, `:commit`, …) and
13
+ # to both controllers and ActiveRecord models.
14
+ #
15
+ # Source shape (frozen into SHAREABLE_DECLARED_CALLBACKS by CallbackCapture):
16
+ # { class_object_id => [ {chain_kind:, phase:, filter:, only:, except:}, … ] }
17
+ # where `chain_kind` is the ActiveSupport::Callbacks chain name (e.g. :save),
18
+ # `phase` is :before / :after, and `only`/`except` are nil or frozen Arrays of
19
+ # action-name Symbols. All values are natively shareable.
20
+ #
21
+ # The transport is duck-typed around `context`:
22
+ # - context.class.ancestors -> Enumerable of class-like objects with object_id
23
+ # - context.class.object_id -> the key the source is indexed by
24
+ # - context.action_name -> Symbol or nil (only meaningful for controllers)
25
+ # - context.respond_to?(filter, true) + context.public_send(filter)
26
+ #
27
+ # `:around` symbolic filters are NOT transported (they must wrap the yield, and
28
+ # the Registry owns the single yield). Rails `:around` callbacks are almost
29
+ # always lambdas anyway; documented as a known limitation in ARCHITECTURE.md §5c.
30
+
31
+ module RactorRailsShim
32
+ module Callbacks
33
+ class SymbolicTransport
34
+ # The callback chain kinds this transport owns by default. Controllers
35
+ # use `:process_action`; models use :save/:create/:update/:destroy and
36
+ # their sub-kinds (:validation, :commit, :rollback). Filter methods defined
37
+ # via `define_method(&block)` with an un-shareable Proc (e.g. AR's autosave
38
+ # association callbacks) are rescued and skipped so app `def` callbacks
39
+ # still run.
40
+ DEFAULT_KINDS = [
41
+ :process_action,
42
+ :save, :create, :update, :destroy,
43
+ :validation, :commit, :rollback
44
+ ].freeze
45
+
46
+ # source: a Hash { class_object_id => [entry, …] } as described above, OR a
47
+ # callable returning that Hash, OR a Symbol naming the shareable constant
48
+ # to resolve via const_get (so a worker reads the frozen constant after
49
+ # prepare, not a stale snapshot).
50
+ # kinds: the set of chain kinds this transport owns (default
51
+ # DEFAULT_KINDS, which includes model lifecycle kinds). Unshareable-Proc
52
+ # filters are rescued and skipped automatically.
53
+ def initialize(source:, kinds: DEFAULT_KINDS)
54
+ @source = source
55
+ @kinds = kinds.is_a?(::Set) ? kinds : ::Set.new(kinds.to_a)
56
+ end
57
+
58
+ def source
59
+ s = @source
60
+ return s.call if s.respond_to?(:call)
61
+ return ::RactorRailsShim.const_get(s) if s.is_a?(::Symbol)
62
+ s
63
+ rescue StandardError
64
+ nil
65
+ end
66
+
67
+ # Whether this transport owns `kind`. Configured by `kinds:` so the
68
+ # registry can compose multiple symbolic transports for different kind
69
+ # sets without editing this class (Open/Closed).
70
+ def applies_to?(kind)
71
+ @kinds.include?(kind)
72
+ end
73
+
74
+ # Run the matching :before filters for `kind`, ancestor-first, respecting
75
+ # only/except. `respond_to?` guards each send so a stale capture never
76
+ # raises NoMethodError (matches the original controller replay behavior).
77
+ #
78
+ # A filter defined via `define_method(&block)` with an un-shareable Proc
79
+ # raises "defined with an un-shareable Proc in a different Ractor" when
80
+ # `send`-ed in a worker. ActiveRecord generates such methods for autosave
81
+ # associations (e.g. `autosave_associated_records_for_*`). We SKIP those
82
+ # filters and continue the chain so app-defined `def` callbacks still run.
83
+ def before(context, kind)
84
+ each_applicable_filter(context, kind, :before) do |entry|
85
+ next unless condition_allows?(context, entry)
86
+ context.send(entry[:filter]) if context.respond_to?(entry[:filter], true)
87
+ rescue RuntimeError => e
88
+ raise e unless unshareable_proc_error?(e)
89
+ # Skip the unshareable-Proc filter; the chain continues.
90
+ end
91
+ end
92
+
93
+ # Run the matching :after filters for `kind`, ancestor-first.
94
+ def after(context, kind)
95
+ each_applicable_filter(context, kind, :after) do |entry|
96
+ next unless condition_allows?(context, entry)
97
+ context.send(entry[:filter]) if context.respond_to?(entry[:filter], true)
98
+ rescue RuntimeError => e
99
+ raise e unless unshareable_proc_error?(e)
100
+ end
101
+ end
102
+
103
+ private
104
+
105
+ # Walk the context's class hierarchy, collecting every entry whose
106
+ # chain_kind + phase match, then yield them in Rails' accumulation order:
107
+ # superclass filters BEFORE subclass filters, declaration order preserved
108
+ # within each class. `only`/`except` gate each entry against the context's
109
+ # `action_name` when present.
110
+ def each_applicable_filter(context, kind, phase)
111
+ action = action_name_of(context)
112
+ ancestors = ancestors_of(context)
113
+ # ancestors is instance-class-first; we want superclass-first so
114
+ # superclass filters run before subclass filters (Rails order), but
115
+ # declaration order is preserved within each class (no reversal).
116
+ collected = []
117
+ table = source
118
+ return unless table # no captured table yet → nothing to replay
119
+ ancestors.reverse_each do |klass|
120
+ entries = table[class_id_of(klass)]
121
+ next unless entries
122
+ entries.each do |entry|
123
+ next unless entry[:chain_kind] == kind
124
+ next unless entry[:phase] == phase
125
+ next unless action_constraint_allows?(entry, action)
126
+ collected << entry
127
+ end
128
+ end
129
+ collected.each { |e| yield e }
130
+ end
131
+
132
+ def action_name_of(context)
133
+ action = context.action_name if context.respond_to?(:action_name)
134
+ action = action.to_sym if action
135
+ action
136
+ rescue StandardError
137
+ nil
138
+ end
139
+
140
+ def ancestors_of(context)
141
+ klass = context.class
142
+ klass.respond_to?(:ancestors) ? klass.ancestors : [klass]
143
+ end
144
+
145
+ def class_id_of(klass)
146
+ klass.object_id
147
+ end
148
+
149
+ # Whether an exception is the "un-shareable Proc in a different Ractor"
150
+ # RuntimeError raised by `send`-ing a `define_method(&block)` method
151
+ # cross-Ractor. We match on a substring so the check survives minor
152
+ # wording changes in the Ruby error message.
153
+ def unshareable_proc_error?(error)
154
+ error.message.include?("un-shareable Proc")
155
+ end
156
+
157
+ # only: nil (always), [:a, :b] (only those actions); except: nil (never
158
+ # skip), [:a] (skip those). nil action means "no action context" — only
159
+ # runs if the filter has no :only constraint (i.e. :only is nil).
160
+ def action_constraint_allows?(entry, action)
161
+ only = entry[:only]
162
+ except = entry[:except]
163
+ in_only = only.nil? || (action && only.include?(action))
164
+ not_except = except.nil? || !(action && except.include?(action))
165
+ in_only && not_except
166
+ end
167
+
168
+ # Check Symbol if:/unless: conditions on the callback entry against the
169
+ # context. `if_cond` / `unless_cond` are Symbol method names (or nil).
170
+ # The method is called on the context; truthy = run, falsy = skip.
171
+ # Non-Symbol conditions (lambdas/Procs) are NOT captured (they're
172
+ # unshareable), so nil means "no condition" (always allow).
173
+ def condition_allows?(context, entry)
174
+ if entry[:if_cond]
175
+ return false unless context.respond_to?(entry[:if_cond], true) &&
176
+ context.send(entry[:if_cond])
177
+ end
178
+ if entry[:unless_cond]
179
+ return false if context.respond_to?(entry[:unless_cond], true) &&
180
+ context.send(entry[:unless_cond])
181
+ end
182
+ true
183
+ rescue StandardError
184
+ # If the condition method raises, skip the callback (safer than
185
+ # running it unconditionally).
186
+ false
187
+ end
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Callbacks — the generalized callback-transport layer (POODR §3 duck-typed
4
+ # interface, §5 message-based design, Open/Closed). See ARCHITECTURE.md §5c.
5
+ #
6
+ # This barrel requires the Registry + the two built-in transports and exposes
7
+ # `Callbacks.registry`, a per-Ractor Registry wired to the shim's shareable
8
+ # callback tables. The storage strategy's `replay_callbacks!` delegates to it
9
+ # and the nil-safe `run_callbacks` patch triggers it on an empty chain. Adding a
10
+ # new callback *kind* means registering a new transport here — no `if kind ==`
11
+ # branches anywhere in the hot path.
12
+ #
13
+ # Ractor note: the Registry/transport objects are built PER Ractor and memoized
14
+ # in Ractor.current (NOT in a class ivar) because (a) class/module ivars set in
15
+ # the main Ractor raise IsolationError when a worker reads them, and (b) the
16
+ # transports' source is a Symbol (the constant name) resolved via const_get,
17
+ # which works in any Ractor — the frozen shareable constants ARE readable
18
+ # cross-Ractor even though ivars are not.
19
+
20
+ require_relative "callbacks/registry"
21
+ require_relative "callbacks/symbolic_transport"
22
+ require_relative "callbacks/dependent_association_transport"
23
+
24
+ module RactorRailsShim
25
+ module Callbacks
26
+ # The constant names the built-in transports resolve at replay time.
27
+ DECLARED_CALLBACKS_CONST = :SHAREABLE_DECLARED_CALLBACKS
28
+ DEPENDENT_ASSOCIATIONS_CONST = :SHAREABLE_DEPENDENT_ASSOCIATIONS
29
+
30
+ class << self
31
+ # The active Registry for THIS Ractor. Built once per Ractor and cached
32
+ # in Ractor.current (a class ivar would raise IsolationError in workers).
33
+ # Tests may replace it via `registry=` (per-Ractor) to inject fakes.
34
+ def registry
35
+ cache = Ractor.current
36
+ cache[:rrs_callbacks_registry] ||= build_default_registry
37
+ end
38
+
39
+ def registry=(value)
40
+ Ractor.current[:rrs_callbacks_registry] = value
41
+ end
42
+
43
+ # Forget the cached registry (e.g. after a re-prepare). Per-Ractor.
44
+ def reset_registry
45
+ Ractor.current[:rrs_callbacks_registry] = nil
46
+ end
47
+
48
+ # Build a fresh Registry wired to the shim's shareable callback tables.
49
+ # Public so tests/integration can rebuild after a re-prepare. The
50
+ # transports take the CONSTANT NAME (a Symbol) and resolve it themselves
51
+ # via const_get at replay time, so no main-Ractor-bound object crosses the
52
+ # boundary.
53
+ def build_default_registry
54
+ Registry.new([
55
+ SymbolicTransport.new(source: DECLARED_CALLBACKS_CONST),
56
+ DependentAssociationTransport.new(source: DEPENDENT_ASSOCIATIONS_CONST)
57
+ ])
58
+ end
59
+ end
60
+ end
61
+ end