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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +83 -0
- data/lib/ractor_rails_shim/callbacks/dependent_association_transport.rb +80 -0
- data/lib/ractor_rails_shim/callbacks/registry.rb +71 -0
- data/lib/ractor_rails_shim/callbacks/symbolic_transport.rb +190 -0
- data/lib/ractor_rails_shim/callbacks.rb +61 -0
- data/lib/ractor_rails_shim/foundation/storage_strategy.rb +190 -71
- data/lib/ractor_rails_shim/loader.rb +8 -0
- data/lib/ractor_rails_shim/patches/action_controller.rb +179 -0
- data/lib/ractor_rails_shim/patches/action_dispatch.rb +109 -0
- data/lib/ractor_rails_shim/patches/action_view.rb +161 -3
- data/lib/ractor_rails_shim/patches/active_model_attribute.rb +63 -0
- data/lib/ractor_rails_shim/patches/active_record_model_schema.rb +61 -0
- data/lib/ractor_rails_shim/patches/active_record_store.rb +169 -0
- data/lib/ractor_rails_shim/patches/active_storage.rb +490 -0
- data/lib/ractor_rails_shim/patches/active_support.rb +13 -6
- data/lib/ractor_rails_shim/patches/activerecord.rb +820 -17
- data/lib/ractor_rails_shim/patches/activerecord_reflection.rb +317 -0
- data/lib/ractor_rails_shim/patches/class_attribute.rb +33 -11
- data/lib/ractor_rails_shim/patches/core.rb +15 -0
- data/lib/ractor_rails_shim/patches/devise.rb +38 -0
- data/lib/ractor_rails_shim/patches/kaminari.rb +35 -0
- data/lib/ractor_rails_shim/patches/mail.rb +502 -0
- data/lib/ractor_rails_shim/patches/marcel.rb +191 -0
- data/lib/ractor_rails_shim/patches/mattr_accessor.rb +73 -2
- data/lib/ractor_rails_shim/patches/route_helpers.rb +10 -1
- data/lib/ractor_rails_shim/roles/app_shareabilizer.rb +13 -0
- data/lib/ractor_rails_shim/roles/callback_capture.rb +40 -16
- data/lib/ractor_rails_shim/roles/fallback_builder.rb +42 -8
- data/lib/ractor_rails_shim/roles/install_strategy.rb +32 -0
- data/lib/ractor_rails_shim/roles/lifecycle.rb +2 -0
- data/lib/ractor_rails_shim/roles/shareability_traversal.rb +42 -0
- data/lib/ractor_rails_shim/version.rb +1 -1
- metadata +10 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c9a5a31eef9fc564a38af828fa80c55909878e502952358a129760441b36844b
|
|
4
|
+
data.tar.gz: ed5bee3ef13dd02d95ab06e806cebc2a1248da438e6f82133654419a85485c28
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|