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.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +83 -0
  3. data/README.md +113 -39
  4. data/lib/ractor_rails_shim/callbacks/dependent_association_transport.rb +80 -0
  5. data/lib/ractor_rails_shim/callbacks/registry.rb +71 -0
  6. data/lib/ractor_rails_shim/callbacks/symbolic_transport.rb +190 -0
  7. data/lib/ractor_rails_shim/callbacks.rb +61 -0
  8. data/lib/ractor_rails_shim/foundation/const_reassign.rb +26 -0
  9. data/lib/ractor_rails_shim/foundation/funnel.rb +47 -0
  10. data/lib/ractor_rails_shim/foundation/ies_accessor.rb +58 -0
  11. data/lib/ractor_rails_shim/foundation/registry.rb +77 -0
  12. data/lib/ractor_rails_shim/foundation/role_defaults.rb +58 -0
  13. data/lib/ractor_rails_shim/foundation/run_mode.rb +76 -0
  14. data/lib/ractor_rails_shim/foundation/storage.rb +99 -0
  15. data/lib/ractor_rails_shim/foundation/storage_strategy.rb +292 -0
  16. data/lib/ractor_rails_shim/foundation/version_policy.rb +147 -0
  17. data/lib/ractor_rails_shim/loader.rb +87 -0
  18. data/lib/ractor_rails_shim/patches/action_controller.rb +188 -9
  19. data/lib/ractor_rails_shim/patches/action_dispatch.rb +183 -15
  20. data/lib/ractor_rails_shim/patches/action_view.rb +186 -28
  21. data/lib/ractor_rails_shim/patches/active_model_attribute.rb +125 -5
  22. data/lib/ractor_rails_shim/patches/active_record_model_schema.rb +66 -5
  23. data/lib/ractor_rails_shim/patches/active_record_store.rb +169 -0
  24. data/lib/ractor_rails_shim/patches/active_storage.rb +490 -0
  25. data/lib/ractor_rails_shim/patches/active_support.rb +55 -586
  26. data/lib/ractor_rails_shim/patches/activerecord.rb +908 -105
  27. data/lib/ractor_rails_shim/patches/activerecord_reflection.rb +317 -0
  28. data/lib/ractor_rails_shim/patches/callables.rb +60 -6
  29. data/lib/ractor_rails_shim/patches/class_attribute.rb +47 -76
  30. data/lib/ractor_rails_shim/patches/core.rb +143 -678
  31. data/lib/ractor_rails_shim/patches/devise.rb +48 -5
  32. data/lib/ractor_rails_shim/patches/execution_wrapper.rb +3 -3
  33. data/lib/ractor_rails_shim/patches/hash_compute_if_absent.rb +98 -0
  34. data/lib/ractor_rails_shim/patches/i18n.rb +455 -0
  35. data/lib/ractor_rails_shim/patches/kaminari.rb +40 -5
  36. data/lib/ractor_rails_shim/patches/mail.rb +502 -0
  37. data/lib/ractor_rails_shim/patches/make_shareable.rb +21 -772
  38. data/lib/ractor_rails_shim/patches/marcel.rb +191 -0
  39. data/lib/ractor_rails_shim/patches/mattr_accessor.rb +88 -8
  40. data/lib/ractor_rails_shim/patches/orm_adapter.rb +3 -3
  41. data/lib/ractor_rails_shim/patches/propshaft.rb +4 -4
  42. data/lib/ractor_rails_shim/patches/rack.rb +15 -15
  43. data/lib/ractor_rails_shim/patches/rails_module.rb +23 -23
  44. data/lib/ractor_rails_shim/patches/route_helpers.rb +13 -4
  45. data/lib/ractor_rails_shim/patches/url_helpers.rb +2 -2
  46. data/lib/ractor_rails_shim/patches/warden.rb +2 -2
  47. data/lib/ractor_rails_shim/patches/zeitwerk_registry.rb +4 -4
  48. data/lib/ractor_rails_shim/patches.rb +4 -102
  49. data/lib/ractor_rails_shim/roles/app_shareabilizer.rb +220 -0
  50. data/lib/ractor_rails_shim/roles/ar_model_walker.rb +93 -0
  51. data/lib/ractor_rails_shim/roles/callback_capture.rb +250 -0
  52. data/lib/ractor_rails_shim/roles/check.rb +210 -0
  53. data/lib/ractor_rails_shim/roles/constant_shareabilizer.rb +217 -0
  54. data/lib/ractor_rails_shim/roles/fallback_builder.rb +367 -0
  55. data/lib/ractor_rails_shim/roles/fallback_ies.rb +25 -0
  56. data/lib/ractor_rails_shim/roles/freezers.rb +341 -0
  57. data/lib/ractor_rails_shim/roles/install_strategy.rb +100 -0
  58. data/lib/ractor_rails_shim/roles/installer.rb +89 -0
  59. data/lib/ractor_rails_shim/roles/lifecycle.rb +41 -0
  60. data/lib/ractor_rails_shim/roles/logger_io_neutralizer.rb +146 -0
  61. data/lib/ractor_rails_shim/roles/pre_spawn_steps.rb +36 -0
  62. data/lib/ractor_rails_shim/roles/shareability_traversal.rb +530 -0
  63. data/lib/ractor_rails_shim/roles/worker_app.rb +77 -0
  64. data/lib/ractor_rails_shim/roles/worker_app_factory.rb +124 -0
  65. data/lib/ractor_rails_shim/version.rb +1 -1
  66. data/lib/ractor_rails_shim.rb +1 -2
  67. metadata +39 -5
  68. data/lib/ractor_rails_shim/check.rb +0 -200
  69. data/lib/ractor_rails_shim/fallback_ies.rb +0 -47
  70. data/lib/ractor_rails_shim/version_policy.rb +0 -72
  71. /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: 21002fee5a30f663904441d8c016c035564095bf456048a25917657e9adab5fc
4
- data.tar.gz: dcb6764139de152ae7b958112503efc7951d59ae211c04ee268da6bf63a2be51
3
+ metadata.gz: c9a5a31eef9fc564a38af828fa80c55909878e502952358a129760441b36844b
4
+ data.tar.gz: ed5bee3ef13dd02d95ab06e806cebc2a1248da438e6f82133654419a85485c28
5
5
  SHA512:
6
- metadata.gz: 28cf75ae0f4972142cee3965d5c68fd8b70c09b901dc60c186f178ac403fc7695c3f7e2e77e4591c9aa6aa0fc9c6d1ba5eafa8e3c64d5abad545b50159159d6c
7
- data.tar.gz: b1bf30685c4b7082c5930a63693571e4d3affff2fa0b530fd355aaf5a57f5cf42280ff5a590f099705e8af573ae953d654ace88fbb813dc05e463379fa0c09c7
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 # entry point — autoload-install if Rails is loaded
67
+ lib/ractor_rails_shim.rb # entry point — autoload-install if Rails is loaded
68
68
  lib/ractor_rails_shim/
69
- version.rb # VERSION constant (currently 0.2.5)
70
- version_check.rb # Gem::Version-based Ruby/Rails detection + policy
71
- fallback_ies.rb # thread-local IES shim when ActiveSupport is absent
72
- check.rb # the ractor-rails-check audit (Check.scan / report)
73
- patches.rb # requires all per-concern patch files in order
74
- patches/
75
- core.rb # module skeleton, registries, install, prepare_for_ractors!, WorkerApp
76
- make_shareable.rb # make_app_shareable!, callable/lock replacement, shareable fallback
77
- rails_module.rb # Rails.application / Rails.cache / Rails.logger / Rails.env …
78
- mattr_accessor.rb # Module#mattr_accessor / cattr_accessor macro rewrite
79
- class_attribute.rb # ActiveSupport class_attribute macro rewrite
80
- zeitwerk_registry.rb # Zeitwerk::Registry class ivars
81
- route_helpers.rb # ActionDispatch::Routing::RouteSet#generate_url_helpers
82
- url_helpers.rb # URL helper singleton + module fixes
83
- execution_wrapper.rb # ActiveSupport::ExecutionWrapper + callback replay
84
- rack.rb # Rack::Request / Rack::Utils
85
- action_view.rb # PathRegistry, LookupContext, Template handlers, compiled_method_container
86
- action_controller.rb # AbstractController, ActionController name/encoding
87
- action_dispatch.rb # ActionDispatch routing, http_url, journey, mounted helpers
88
- polymorphic_routes.rb # polymorphic_path(s) URL helpers
89
- active_support.rb # Inflector, error_reporter, ExecutionContext, I18n, JSON encoding, Reloader
90
- warden.rb # Warden hooks / strategies / serializer
91
- devise.rb # Devise url_helpers / authenticatable / failure_app
92
- active_model_attribute.rb # ActiveModel::Attribute dup_or_share for frozen graphs
93
- active_record_model_schema.rb # AR ModelSchema reload_schema_from_cache (worker-safe)
94
- activerecord.rb # AR connection handler, configurations, query caches, …
95
- kaminari.rb # Kaminari config
96
- propshaft.rb # Propshaft asset server
97
- orm_adapter.rb # orm_adapter
98
- openssl.rb # OpenSSL digest cache
99
- rubygems.rb # Rubygems msgpack pre-check
100
- exe/ractor-rails-check # CLI audit tool
101
- spec/ # 7 spec files, 61 unit tests (no Rails dep) + integration spec
102
- script/make_test_app.sh # build the minimal Rails 8.1 test app (CI uses this)
103
- script/make_full_test_app.sh # build the full-featured test app (Devise/PG/Kaminari)
104
- .github/workflows/ci.yml # unit job + integration job (GET /up → 200 in a worker Ractor)
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. `patches.rb` requires them in dependency order
109
- (`core.rb` first — it defines the module skeleton + registries the rest
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