ractor-rails-shim 0.3.0 → 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/README.md +113 -39
- 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/const_reassign.rb +26 -0
- data/lib/ractor_rails_shim/foundation/funnel.rb +47 -0
- data/lib/ractor_rails_shim/foundation/ies_accessor.rb +58 -0
- data/lib/ractor_rails_shim/foundation/registry.rb +77 -0
- data/lib/ractor_rails_shim/foundation/role_defaults.rb +58 -0
- data/lib/ractor_rails_shim/foundation/run_mode.rb +76 -0
- data/lib/ractor_rails_shim/foundation/storage.rb +99 -0
- data/lib/ractor_rails_shim/foundation/storage_strategy.rb +292 -0
- data/lib/ractor_rails_shim/foundation/version_policy.rb +147 -0
- data/lib/ractor_rails_shim/loader.rb +87 -0
- data/lib/ractor_rails_shim/patches/action_controller.rb +188 -9
- data/lib/ractor_rails_shim/patches/action_dispatch.rb +183 -15
- data/lib/ractor_rails_shim/patches/action_view.rb +186 -28
- data/lib/ractor_rails_shim/patches/active_model_attribute.rb +125 -5
- data/lib/ractor_rails_shim/patches/active_record_model_schema.rb +66 -5
- 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 +55 -586
- data/lib/ractor_rails_shim/patches/activerecord.rb +908 -105
- data/lib/ractor_rails_shim/patches/activerecord_reflection.rb +317 -0
- data/lib/ractor_rails_shim/patches/callables.rb +60 -6
- data/lib/ractor_rails_shim/patches/class_attribute.rb +47 -76
- data/lib/ractor_rails_shim/patches/core.rb +143 -678
- data/lib/ractor_rails_shim/patches/devise.rb +48 -5
- data/lib/ractor_rails_shim/patches/execution_wrapper.rb +3 -3
- data/lib/ractor_rails_shim/patches/hash_compute_if_absent.rb +98 -0
- data/lib/ractor_rails_shim/patches/i18n.rb +455 -0
- data/lib/ractor_rails_shim/patches/kaminari.rb +40 -5
- data/lib/ractor_rails_shim/patches/mail.rb +502 -0
- data/lib/ractor_rails_shim/patches/make_shareable.rb +21 -772
- data/lib/ractor_rails_shim/patches/marcel.rb +191 -0
- data/lib/ractor_rails_shim/patches/mattr_accessor.rb +88 -8
- data/lib/ractor_rails_shim/patches/orm_adapter.rb +3 -3
- data/lib/ractor_rails_shim/patches/propshaft.rb +4 -4
- data/lib/ractor_rails_shim/patches/rack.rb +15 -15
- data/lib/ractor_rails_shim/patches/rails_module.rb +23 -23
- data/lib/ractor_rails_shim/patches/route_helpers.rb +13 -4
- data/lib/ractor_rails_shim/patches/url_helpers.rb +2 -2
- data/lib/ractor_rails_shim/patches/warden.rb +2 -2
- data/lib/ractor_rails_shim/patches/zeitwerk_registry.rb +4 -4
- data/lib/ractor_rails_shim/patches.rb +4 -102
- data/lib/ractor_rails_shim/roles/app_shareabilizer.rb +220 -0
- data/lib/ractor_rails_shim/roles/ar_model_walker.rb +93 -0
- data/lib/ractor_rails_shim/roles/callback_capture.rb +250 -0
- data/lib/ractor_rails_shim/roles/check.rb +210 -0
- data/lib/ractor_rails_shim/roles/constant_shareabilizer.rb +217 -0
- data/lib/ractor_rails_shim/roles/fallback_builder.rb +367 -0
- data/lib/ractor_rails_shim/roles/fallback_ies.rb +25 -0
- data/lib/ractor_rails_shim/roles/freezers.rb +341 -0
- data/lib/ractor_rails_shim/roles/install_strategy.rb +100 -0
- data/lib/ractor_rails_shim/roles/installer.rb +89 -0
- data/lib/ractor_rails_shim/roles/lifecycle.rb +41 -0
- data/lib/ractor_rails_shim/roles/logger_io_neutralizer.rb +146 -0
- data/lib/ractor_rails_shim/roles/pre_spawn_steps.rb +36 -0
- data/lib/ractor_rails_shim/roles/shareability_traversal.rb +530 -0
- data/lib/ractor_rails_shim/roles/worker_app.rb +77 -0
- data/lib/ractor_rails_shim/roles/worker_app_factory.rb +124 -0
- data/lib/ractor_rails_shim/version.rb +1 -1
- data/lib/ractor_rails_shim.rb +1 -2
- metadata +39 -5
- data/lib/ractor_rails_shim/check.rb +0 -200
- data/lib/ractor_rails_shim/fallback_ies.rb +0 -47
- data/lib/ractor_rails_shim/version_policy.rb +0 -72
- /data/lib/ractor_rails_shim/{version_check.rb → foundation/version_check.rb} +0 -0
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)
|
data/README.md
CHANGED
|
@@ -64,49 +64,123 @@ kino `:ractor` vs Puma vs Falcon are documented in
|
|
|
64
64
|
## Repository layout
|
|
65
65
|
|
|
66
66
|
```
|
|
67
|
-
lib/ractor_rails_shim.rb
|
|
67
|
+
lib/ractor_rails_shim.rb # entry point — autoload-install if Rails is loaded
|
|
68
68
|
lib/ractor_rails_shim/
|
|
69
|
-
version.rb
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
.
|
|
69
|
+
version.rb # VERSION constant (currently 0.3.0)
|
|
70
|
+
loader.rb # pure require hub — one file, one job (POODR §1)
|
|
71
|
+
patches.rb # backward-compat redirect → loader.rb
|
|
72
|
+
|
|
73
|
+
foundation/ # Layer 2: no internal deps
|
|
74
|
+
funnel.rb # debug-aware exception-swallowing role
|
|
75
|
+
registry.rb # canonical home for the nine shared registries
|
|
76
|
+
storage.rb # pluggable key-value store contract (IES / ThreadLocal)
|
|
77
|
+
storage_strategy.rb # composed Ractor/Thread strategy for class_attribute
|
|
78
|
+
ies_accessor.rb # 3-tier IES lookup pattern generator
|
|
79
|
+
const_reassign.rb # $VERBOSE-suppressed const_set utility
|
|
80
|
+
version_check.rb # Gem::Version-based Ruby/Rails detection
|
|
81
|
+
version_policy.rb # mismatch policy (:warn/:strict/:off) + patch registry
|
|
82
|
+
run_mode.rb # thread vs Ractor mode decision
|
|
83
|
+
role_defaults.rb # DRY mixin for shared default-proc patterns
|
|
84
|
+
|
|
85
|
+
roles/ # Layer 3: depend on foundation
|
|
86
|
+
lifecycle.rb # prepare_for_ractors! orchestration
|
|
87
|
+
installer.rb # install orchestrator + framework-patch dispatcher
|
|
88
|
+
install_strategy.rb # per-mode install bodies (Ractor / Thread)
|
|
89
|
+
pre_spawn_steps.rb # shared orchestration steps
|
|
90
|
+
ar_model_walker.rb # ActiveRecord model enumeration
|
|
91
|
+
constant_shareabilizer.rb # constant shareability
|
|
92
|
+
shareability_traversal.rb # app-graph traversal
|
|
93
|
+
callback_capture.rb # callback-declaration capture
|
|
94
|
+
fallback_builder.rb # shareable-fallback builder
|
|
95
|
+
worker_app.rb # shareable Rack wrapper (per-worker init)
|
|
96
|
+
worker_app_factory.rb # shareable-Rack-app factory
|
|
97
|
+
app_shareabilizer.rb # make_app_shareable! orchestrator
|
|
98
|
+
freezers.rb # freeze/warm sub-domain (6 freezer roles)
|
|
99
|
+
logger_io_neutralizer.rb # logger IO detachment
|
|
100
|
+
fallback_ies.rb # backward-compat alias → Storage::ThreadLocal
|
|
101
|
+
check.rb # ractor-rails-check audit (Check.scan / report)
|
|
102
|
+
|
|
103
|
+
patches/ # Layer 4: monkey-patches (depend on roles + foundation)
|
|
104
|
+
core.rb # module skeleton, registries, facade delegations
|
|
105
|
+
callables.rb # NoOpProc, Callable, CallableConst, RequestCallable, …
|
|
106
|
+
make_shareable.rb # make_app_shareable!, proc/lock replacement
|
|
107
|
+
rails_module.rb # Rails.application / Rails.cache / Rails.logger / Rails.env …
|
|
108
|
+
mattr_accessor.rb # Module#mattr_accessor / cattr_accessor macro rewrite
|
|
109
|
+
class_attribute.rb # ActiveSupport class_attribute macro rewrite
|
|
110
|
+
zeitwerk_registry.rb # Zeitwerk::Registry class ivars
|
|
111
|
+
route_helpers.rb # ActionDispatch::Routing::RouteSet#generate_url_helpers
|
|
112
|
+
url_helpers.rb # URL helper singleton + module fixes
|
|
113
|
+
execution_wrapper.rb # ActiveSupport::ExecutionWrapper + callback replay
|
|
114
|
+
rack.rb # Rack::Request / Rack::Utils
|
|
115
|
+
action_view.rb # PathRegistry, LookupContext, Template handlers
|
|
116
|
+
action_controller.rb # AbstractController, ActionController name/encoding
|
|
117
|
+
action_dispatch.rb # ActionDispatch routing, http_url, journey
|
|
118
|
+
polymorphic_routes.rb # polymorphic_path(s) URL helpers
|
|
119
|
+
active_support.rb # Inflector, error_reporter, ExecutionContext, I18n, JSON
|
|
120
|
+
i18n.rb # I18n locale/fallback patches
|
|
121
|
+
warden.rb # Warden hooks / strategies / serializer
|
|
122
|
+
devise.rb # Devise url_helpers / authenticatable / failure_app
|
|
123
|
+
active_model_attribute.rb # ActiveModel::Attribute dup_or_share for frozen graphs
|
|
124
|
+
active_record_model_schema.rb # AR ModelSchema reload_schema_from_cache
|
|
125
|
+
activerecord.rb # AR connection handler, configurations, query caches
|
|
126
|
+
kaminari.rb # Kaminari config
|
|
127
|
+
propshaft.rb # Propshaft asset server
|
|
128
|
+
orm_adapter.rb # orm_adapter
|
|
129
|
+
openssl.rb # OpenSSL digest cache
|
|
130
|
+
rubygems.rb # Rubygems msgpack pre-check
|
|
131
|
+
hash_compute_if_absent.rb # Hash#compute_if_absent for Concurrent::Map replacement
|
|
132
|
+
|
|
133
|
+
exe/ractor-rails-check # CLI audit tool
|
|
134
|
+
spec/ # 724 unit tests (no Rails dep) + integration spec
|
|
135
|
+
script/make_test_app.sh # build the minimal Rails 8.1 test app (CI uses this)
|
|
136
|
+
script/make_full_test_app.sh # build the full-featured test app (Devise/PG/Kaminari)
|
|
137
|
+
.github/workflows/ci.yml # unit job + integration job (GET /up → 200 in a worker Ractor)
|
|
105
138
|
```
|
|
106
139
|
|
|
140
|
+
### Architecture: layered, POODR-aligned design
|
|
141
|
+
|
|
142
|
+
The directory structure mirrors the dependency layers:
|
|
143
|
+
|
|
144
|
+
```
|
|
145
|
+
Layer 1: Entry ractor_rails_shim.rb, loader.rb
|
|
146
|
+
Layer 2: Foundation foundation/ (no internal deps)
|
|
147
|
+
Layer 3: Roles roles/ (depend on foundation)
|
|
148
|
+
Layer 4: Patches patches/ (depend on roles + foundation)
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
The gem follows **Practical Object-Oriented Design in Ruby** (POODR) principles:
|
|
152
|
+
|
|
153
|
+
- **SRP (§1):** Every file defines exactly one concern. `loader.rb` is a pure
|
|
154
|
+
require hub (no logic). `lifecycle.rb` owns `prepare_for_ractors!` orchestration.
|
|
155
|
+
Role objects (`Funnel`, `Registry`, `CallbackCapture`, etc.) each own one
|
|
156
|
+
responsibility.
|
|
157
|
+
|
|
158
|
+
- **Dependencies (§2):** Role objects receive collaborators via `configure` seams
|
|
159
|
+
(keyword arguments), defaulting to facade lookups. No role reaches past its
|
|
160
|
+
own namespace by name — the composition root (`Installer`) is the one place
|
|
161
|
+
allowed to resolve by name.
|
|
162
|
+
|
|
163
|
+
- **Interfaces (§3):** The `Storage` contract (`[]`, `[]=`, `key?`, `delete`)
|
|
164
|
+
is implemented by `Storage::IES` and `Storage::ThreadLocal`. The
|
|
165
|
+
`StorageStrategy` contract (`lookup`, `store`, `replay_callbacks?`,
|
|
166
|
+
`replay_callbacks!`) is implemented by `StorageStrategy::Ractor` and
|
|
167
|
+
`StorageStrategy::Thread`.
|
|
168
|
+
|
|
169
|
+
- **Duck Typing (§4):** `ShareabilityTraversal` uses a `CONTAINER_WALKERS`
|
|
170
|
+
dispatch table (Hash → lambda) instead of `is_a?` chains. Lock detection
|
|
171
|
+
uses `respond_to?(:synchronize)` instead of `is_a?(Mutex)`.
|
|
172
|
+
|
|
173
|
+
- **Composition (§5):** `AppShareabilizer` composes 15 collaborators via the
|
|
174
|
+
configure seam. `FallbackBuilder` uses a `ValueLookup` chain of responsibility.
|
|
175
|
+
`Installer` selects between `InstallStrategy::Ractor` and `InstallStrategy::Thread`.
|
|
176
|
+
|
|
177
|
+
- **Roles (§6):** Modules as roles (`Funnel`, `ARModelWalker`,
|
|
178
|
+
`ConstantShareabilizer`, `CallbackCapture`, etc.) — each `extend RoleDefaults`
|
|
179
|
+
for DRY default-proc patterns.
|
|
180
|
+
|
|
107
181
|
Per-concern patch files reopen `RactorRailsShim`'s singleton class to add
|
|
108
|
-
their `_install_*` methods. `
|
|
109
|
-
(`core.rb` first — it defines the module skeleton +
|
|
182
|
+
their `_install_*` methods. `loader.rb` requires them in dependency order
|
|
183
|
+
(`core.rb` first — it defines the module skeleton + constants that others
|
|
110
184
|
reference). Each `_install_*` method is idempotent (guarded by its own
|
|
111
185
|
`@*_patched` flag), so `install`, `prepare_for_ractors!`, and
|
|
112
186
|
`make_app_shareable!` can all call the full set safely.
|
|
@@ -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
|