solid_objects 0.14.6 → 0.15.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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -0
  3. data/README.md +1 -0
  4. data/Rakefile +1 -1
  5. data/app/models/solid_objects/effect_recovery.rb +10 -0
  6. data/benchmark/support.rb +3 -0
  7. data/db/migrate/20260915000000_add_solid_objects_effect_recoveries.rb +17 -0
  8. data/docs/architecture.md +48 -0
  9. data/docs/development.md +110 -0
  10. data/docs/effect-recovery.md +203 -0
  11. data/docs/operations.md +6 -0
  12. data/docs/reminders.md +8 -0
  13. data/docs/roadmap.md +11 -0
  14. data/examples/at_least_once/boot.rb +3 -1
  15. data/lib/solid_objects/actor.rb +43 -4
  16. data/lib/solid_objects/actor_signatures.rb +80 -0
  17. data/lib/solid_objects/database_adapter.rb +13 -0
  18. data/lib/solid_objects/doctor.rb +1 -0
  19. data/lib/solid_objects/effect_executor.rb +18 -15
  20. data/lib/solid_objects/effect_payload.rb +42 -0
  21. data/lib/solid_objects/effect_recovery_coordinator.rb +143 -0
  22. data/lib/solid_objects/executor.rb +14 -2
  23. data/lib/solid_objects/process_heartbeat.rb +64 -0
  24. data/lib/solid_objects/process_pruner.rb +1 -1
  25. data/lib/solid_objects/process_registry.rb +9 -6
  26. data/lib/solid_objects/test_helper.rb +1 -0
  27. data/lib/solid_objects/version.rb +1 -1
  28. data/lib/solid_objects.rb +3 -0
  29. data/sig/generated/lib/solid_objects/actor.rbs +40 -6
  30. data/sig/generated/lib/solid_objects/actor_signatures.rbs +24 -0
  31. data/sig/generated/lib/solid_objects/database_adapter.rbs +3 -0
  32. data/sig/generated/lib/solid_objects/effect_payload.rbs +23 -0
  33. data/sig/generated/lib/solid_objects/effect_recovery_coordinator.rbs +41 -0
  34. data/sig/generated/lib/solid_objects/process_heartbeat.rbs +38 -0
  35. data/sig/generated/models/solid_objects/effect_recovery.rbs +6 -0
  36. data/sig/public/effect_payload.rbs +33 -0
  37. metadata +15 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '0616851aa1c44a26313dac95fa3f868324910a64a7023435d2c2b6aed83b7610'
4
- data.tar.gz: b54b99a40ee18b68f7d6b7f21eb9879c92a867b0715a52647f4f58b4f35997d9
3
+ metadata.gz: 30a65b9701ec2d566a8d62c2ee428ec61cbfb5bc24d1390905a46943594b78cf
4
+ data.tar.gz: 39daa8fe6b2586902eee4e9bd6fbdeef4e2666651e707d647a91e5644243bb79
5
5
  SHA512:
6
- metadata.gz: 5a1a03e584ad3305114993f1898294ebcc719330d2ea6466aa29b38b218bae50fca92394cc57dac95dd8f584da6c506a57f7c145d4238ed2c06e0411a93fa4a8
7
- data.tar.gz: 45b4818bc0beb1de4697155bd438c58bc3432e2d40e93c1deb6ff6db5c295d54fe9994e021ff5f7167564a3444fd2b8918b669f2dc550a88985b53e57f55d96c
6
+ metadata.gz: 6035b50982322dc2d90c6a6c286dc4ea626402003389f6bfdb66d26fdccb1aa4418cc088accedba0030bafa31e2eb4bb699203c0c2f608ba6e5849c4946f11b3
7
+ data.tar.gz: 43615ab44e86f76db4818a89a0d16971a55c3de959331675aa6d47640ea79db7f3d386c3ce4eefdae9a9c3fdce7aa2527f21754a67a4ab0cede9d45cefb09851
data/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.15.0 - 2026-09-15
4
+
5
+ - Maintain effect-owner heartbeats during long-running handlers, completion, and
6
+ failure handling, so healthy external I/O cannot trigger abandoned recovery.
7
+ Report failed heartbeat updates and retry on the next configured interval
8
+ without consuming effect attempts.
9
+ - Return a stable effect handle from every `emit`. Wrappers must return it;
10
+ operations relying on an implicit `nil` result should return `nil` explicitly.
11
+ - Add abandoned effect recovery with `on_recovery`, optional `on_status`, staged
12
+ `request_effect_recovery`, and an extending `recovery_timeout` in seconds.
13
+ Retirement and durable callbacks share the claim-locking transaction. Add
14
+ frozen outcome constants and public RBS envelopes. Install the new recovery
15
+ binding migration before upgrading runtime processes. External actions still
16
+ require idempotency; retirement does not cancel an old handler or remote call.
17
+
18
+ ## 0.14.7 - 2026-09-14
19
+
20
+ - Publish RBS contracts for effect callback envelopes and Ruby error summaries.
21
+ Check the runtime constructors and packaged consumer signatures strictly,
22
+ preserving ordinary hashes, keyword callbacks, serialization, and retries.
23
+ - Add optional actor-specific RBS generation for staged schedule/transmit calls
24
+ and effect callback names. Reuse application-declared operation argument types
25
+ without changing Ruby dispatch, runtime validation, or global effect registries.
26
+
3
27
  ## 0.14.6 - 2026-09-12
4
28
 
5
29
  - Preserve committed turns when an Active Record after-commit callback raises.
data/README.md CHANGED
@@ -149,6 +149,7 @@ SQL and should be allowed to enjoy that.
149
149
  - One successful turn commits actor state and staged reminders, messages, effects, commit actions, and broadcasts together.
150
150
  - Fencing prevents stale Ruby code from committing, but it cannot stop that code from continuing to run.
151
151
  - External effects can repeat and must deduplicate with the stable effect ID or another durable idempotency key.
152
+ - [Effect recovery](docs/effect-recovery.md) uses stable `emit` handles and `on_recovery` to retire abandoned work atomically with a durable actor callback.
152
153
  - Actor handlers may read application records but cannot write them directly. Use `commit_action` for bounded same-database writes and `emit` for external I/O.
153
154
  - `async`, reminders, effects, and broadcasts need `bundle exec solid_objects start`. Pending work remains in SQL while it is down.
154
155
  - One hot identity is intentionally sequential. There are no transactions across actor identities.
data/Rakefile CHANGED
@@ -14,7 +14,7 @@ task :rbs do
14
14
  FileUtils.rm_rf(File.expand_path("sig/generated", __dir__))
15
15
  sh "bundle exec rbs-inline --base lib --base app --output sig/generated lib app"
16
16
  FileUtils.rm_f(File.expand_path("sig/generated/lib/generators/solid_objects/templates/solid_objects.rbs", __dir__))
17
- sh "bundle exec rbs -I sig/generated -I sig/support validate"
17
+ sh "bundle exec rbs -I sig/generated -I sig/support -I sig/public validate"
18
18
  end
19
19
 
20
20
  desc "Run Standard Ruby"
@@ -0,0 +1,10 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ class EffectRecovery < Record
5
+ self.table_name = SolidObjects.table_name(:effect_recoveries)
6
+ self.primary_key = "effect_id"
7
+
8
+ belongs_to :instance, class_name: "SolidObjects::Instance"
9
+ end
10
+ end
data/benchmark/support.rb CHANGED
@@ -428,9 +428,11 @@ module SolidObjectsBenchmark
428
428
  require_relative "../db/migrate/20260805000000_create_solid_objects_tables"
429
429
  require_relative "../db/migrate/20260806000000_add_state_revision_to_solid_objects_instances"
430
430
  require_relative "../db/migrate/20260813000000_rename_message_dispatch_columns"
431
+ require_relative "../db/migrate/20260915000000_add_solid_objects_effect_recoveries"
431
432
  CreateSolidObjectsTables.new.migrate(:up)
432
433
  AddStateRevisionToSolidObjectsInstances.new.migrate(:up)
433
434
  RenameMessageDispatchColumns.new.migrate(:up)
435
+ AddSolidObjectsEffectRecoveries.new.migrate(:up)
434
436
  end
435
437
 
436
438
  # @rbs () -> void
@@ -444,6 +446,7 @@ module SolidObjectsBenchmark
444
446
  claimed_message
445
447
  reminder
446
448
  effect
449
+ effect_recovery
447
450
  broadcast
448
451
  dead_letter
449
452
  ].each do |model|
@@ -0,0 +1,17 @@
1
+ # rbs_inline: enabled
2
+
3
+ class AddSolidObjectsEffectRecoveries < ActiveRecord::Migration[7.1]
4
+ # @rbs () -> void
5
+ def change
6
+ create_table SolidObjects.table_name(:effect_recoveries), id: :string, limit: 36, primary_key: :effect_id do |definition|
7
+ definition.references :instance, null: false,
8
+ foreign_key: { to_table: SolidObjects.table_name(:instances), on_delete: :cascade, name: "fk_so_effect_recoveries_instance" }
9
+ definition.string :recovery_operation, limit: 191
10
+ definition.string :status_operation, limit: 191
11
+ definition.float :recovery_timeout
12
+ definition.datetime :retired_at, precision: 6
13
+ definition.timestamps precision: 6, null: false
14
+ definition.check_constraint "recovery_timeout IS NULL OR recovery_timeout > 0", name: "chk_so_effect_recoveries_timeout"
15
+ end
16
+ end
17
+ end
data/docs/architecture.md CHANGED
@@ -443,6 +443,10 @@ a dead letter.
443
443
 
444
444
  `emit` creates a staged effect:
445
445
 
446
+ Typed applications can generate [actor-specific RBS signatures](development.md#actor-specific-dispatch-signatures)
447
+ to check callback names and staged `schedule`/`transmit` calls without changing
448
+ their Ruby syntax. Effect and commit-action registry names remain independent.
449
+
446
450
  ```ruby
447
451
  emit(
448
452
  :charge_payment,
@@ -483,6 +487,50 @@ and `result:`. A failure callback receives `effect_id:`, `arguments:`, and
483
487
  `error:`, so an actor can correlate concurrent effects without storing a
484
488
  separate callback ledger.
485
489
 
490
+ ### Typing your on_failure handler
491
+
492
+ The gem ships `SolidObjects::effect_error`,
493
+ `SolidObjects::effect_failure_payload[Arguments]`, and
494
+ `SolidObjects::effect_success_payload[Arguments, Result]` as public RBS aliases.
495
+ They describe the existing string-keyed hashes; they are not Ruby wrapper classes.
496
+ See [signature loading](development.md#public-effect-payload-signatures) for Steep setup.
497
+
498
+ For an actor with `generation` and `status` attributes, declare the callback keywords
499
+ in the application's RBS:
500
+
501
+ ```rbs
502
+ class ChatRun < SolidObjects::Actor
503
+ type run_arguments = { "generation" => Integer }
504
+ def fail_turn: (effect_id: String, arguments: run_arguments, error: SolidObjects::effect_error) -> void
505
+ end
506
+ ```
507
+
508
+ ```ruby
509
+ def fail_turn(effect_id:, arguments:, error:)
510
+ return unless arguments["generation"] == generation
511
+
512
+ self.status = "failed"
513
+ end
514
+ ```
515
+
516
+ Record access with `arguments["generation"]` retains its declared `Integer` type.
517
+ The callback receives top-level keywords, while nested arguments and error keys
518
+ remain strings. The failure envelope requires `"effect_id"`, `"arguments"`, and
519
+ `"error"`; the success envelope replaces `"error"` with `"result"`. Empty original
520
+ arguments remain `{}`, and a success result may be `nil` or any supported JSON value.
521
+ Ruby errors contain `"class"` (`String?`, including anonymous exception classes),
522
+ `"message"` (`String`, limited to 8,192 bytes), and `"backtrace"` (`Array[String]`,
523
+ limited to 50 entries and possibly empty).
524
+
525
+ Applications supply the generic argument/result types to describe their serialized
526
+ JSON values. These aliases do not infer or validate independently registered effect
527
+ handlers. Their type parameters are deliberately unconstrained: Ruby serialization
528
+ accepts and normalizes values such as symbols, and RBS cannot express that conversion
529
+ as a generic bound. The constructors and consumer fixtures are checked with strict
530
+ Steep diagnostics. JavaScript exposes equivalent contracts with its existing
531
+ camelCase ID and `{ name, message }` error shape in
532
+ [solid-objects-js#47](https://github.com/cardmagic/solid-objects-js/issues/47).
533
+
486
534
  A commit action is registered the same way and runs inside the short fenced
487
535
  transaction:
488
536
 
data/docs/development.md CHANGED
@@ -103,6 +103,116 @@ bundle exec rake rbs
103
103
 
104
104
  This follows the inline convention used by `cardmagic/classifier`.
105
105
 
106
+ ### Public effect payload signatures
107
+
108
+ The packaged `sig/public` directory owns the reusable effect payload aliases and
109
+ survives `rake rbs` regeneration. A host application's Steep target can load the
110
+ installed gem's complete signature tree alongside its own signatures:
111
+
112
+ ```ruby
113
+ target :app do
114
+ library "solid_objects"
115
+ signature "sig"
116
+ check "app/actors"
117
+ configure_code_diagnostics(Diagnostic::Ruby.strict)
118
+ end
119
+ ```
120
+
121
+ This requires no internal runtime imports. See the
122
+ [typed callback example](architecture.md#typing-your-on_failure-handler).
123
+ The gem's strict payload target checks the actual constructors; its packaged
124
+ consumer test also verifies that missing keys and incorrect field types fail.
125
+
126
+ ### Actor-specific dispatch signatures
127
+
128
+ Typed applications can opt into `SolidObjects::ActorSignatures` to check ordinary
129
+ `schedule`, `transmit`, and effect callback names inside actor methods. Add `rbs`
130
+ and `steep` to the application's development dependencies. This tool is loaded
131
+ explicitly and is not required by workers or ordinary Ruby applications.
132
+
133
+ Declare application operation types first, including inherited operations and
134
+ block-defined `message` operations. Inline RBS can generate this input, or keep
135
+ handwritten declarations in a separate directory such as `sig/actors`:
136
+
137
+ ```rbs
138
+ class ChatRun < SolidObjects::Actor
139
+ def recover_if_stuck: (generation: Integer) -> nil
140
+ def fail_turn: (effect_id: String, arguments: Hash[String, untyped], error: Hash[String, untyped]) -> nil
141
+ def start: () -> nil
142
+ end
143
+ ```
144
+
145
+ After loading the application's actor classes, generate a separate output file:
146
+
147
+ ```ruby
148
+ require "solid_objects/actor_signatures"
149
+
150
+ Rails.application.reloader.wrap do
151
+ signatures = SolidObjects::ActorSignatures.generate(
152
+ actors: [ChatRun],
153
+ signatures: [Rails.root.join("sig/actors").to_s]
154
+ )
155
+ FileUtils.mkdir_p(Rails.root.join("sig/generated"))
156
+ File.write(Rails.root.join("sig/generated/solid_objects.rbs"), signatures)
157
+ end
158
+ ```
159
+
160
+ Run that script with `bin/rails runner` during development or CI. Outside Rails,
161
+ require the actor definitions and call `generate` directly. The generator returns
162
+ a string and does not write files, execute actor operations, start workers, or run
163
+ migrations. Rails boot follows the application's normal loading configuration;
164
+ the explicit actor list resolves its autoloaded classes within the reloader boundary.
165
+ Keep generated output out of the input signature paths, and regenerate after a
166
+ message is renamed or removed. Output is deterministic; handwritten signatures
167
+ remain separate.
168
+
169
+ Load the gem and both application signature directories in `Steepfile`:
170
+
171
+ ```ruby
172
+ target :actors do
173
+ library "solid_objects"
174
+ signature "sig/actors"
175
+ signature "sig/generated"
176
+ check "app/actors"
177
+ configure_code_diagnostics(Diagnostic::Ruby.strict)
178
+ end
179
+ ```
180
+
181
+ The usual Ruby code now passes Steep without a dispatcher cast:
182
+
183
+ ```ruby
184
+ schedule(at: Time.now, key: "watchdog").recover_if_stuck(generation: 1)
185
+ transmit.recover_if_stuck(generation: 1)
186
+ emit :run_model, generation: 1, on_failure: :fail_turn
187
+ ```
188
+
189
+ Misspelled operations/callbacks, queries, attributes, private methods, infrastructure
190
+ methods, and incorrect keyword arguments fail the strict check. Both string and
191
+ symbol callback literals work. Staging returns `nil` even when an operation's own
192
+ return type differs. Effect and commit-action names remain global registry names;
193
+ registry contract inference is separate work.
194
+
195
+ Reflection supplies message names only. Values come from declared RBS signatures;
196
+ missing signatures and positional/block arguments are rejected. Block-defined
197
+ messages require explicit method declarations in RBS; annotate their block-local
198
+ values separately when checking the block body. Generic actor classes currently
199
+ require application-owned dispatcher signatures. The generator preserves method
200
+ overloads and method type parameters.
201
+
202
+ Deliberately dynamic names can use Ruby's explicit dynamic dispatch:
203
+
204
+ ```ruby
205
+ schedule(at: Time.now).public_send(operation_name, generation: generation)
206
+ public_send(:emit, :run_model, on_failure: callback_name, generation: generation)
207
+ ```
208
+
209
+ Those calls opt out of name/argument checking and retain the existing runtime
210
+ validation. Ordinary calls on the generated dispatcher have no string-name fallback.
211
+ `send_to`, `Reference#async`, direct calls, and queries retain their existing
212
+ signatures and runtime behavior; this generator does not provide complete reference
213
+ typing or Sorbet/Tapioca actor-specific RBI generation. The corresponding TypeScript
214
+ work is tracked in [solid-objects-js#46](https://github.com/cardmagic/solid-objects-js/issues/46).
215
+
106
216
  ## Formatting and security
107
217
 
108
218
  ```bash
@@ -0,0 +1,203 @@
1
+ # Effect recovery coordination
2
+
3
+ `emit` returns a JSON-serializable handle containing the public `effect_id`.
4
+ Registering `on_recovery` opts the effect into retirement when its owner has
5
+ stopped heartbeating. `on_status` is optional and receives responses only to
6
+ explicit `request_effect_recovery(handle)` intents. Normal success and failure
7
+ retain their existing callbacks.
8
+
9
+ ## Watchdog using supported APIs
10
+
11
+ ```ruby
12
+ class ReportExport < SolidObjects::Actor
13
+ attribute :revision, default: 0
14
+ attribute :export_effect, default: nil
15
+ attribute :artifact_key, default: ""
16
+ attribute :applied_effect_id, default: nil
17
+
18
+ def start
19
+ self.revision += 1
20
+ self.export_effect = emit(:build_report,
21
+ revision: revision,
22
+ on_success: :export_finished,
23
+ on_failure: :export_failed,
24
+ on_recovery: :recover_export,
25
+ on_status: :inspect_export,
26
+ recovery_timeout: 120)
27
+ schedule(at: Time.now + 30, key: "export-watchdog").watchdog
28
+ nil
29
+ end
30
+
31
+ def watchdog
32
+ request_effect_recovery(export_effect) if export_effect
33
+ end
34
+
35
+ def recover_export(effect_id:, arguments:, outcome:)
36
+ return unless effect_id == export_effect&.fetch("effect_id")
37
+ return unless arguments.fetch("revision") == revision
38
+
39
+ start
40
+ end
41
+
42
+ def export_finished(effect_id:, arguments:, result:)
43
+ apply_export_result(effect_id:, arguments:, result:)
44
+ end
45
+
46
+ def export_failed(effect_id:, arguments:, error:)
47
+ end
48
+
49
+ def inspect_export(effect_id:, outcome:, arguments: nil, result: nil)
50
+ return unless effect_id == export_effect&.fetch("effect_id")
51
+
52
+ case outcome
53
+ when SolidObjects::EffectRecoveryOutcome::COMPLETED
54
+ apply_export_result(effect_id:, arguments:, result:)
55
+ when SolidObjects::EffectRecoveryOutcome::DEFERRED, SolidObjects::EffectRecoveryOutcome::PENDING
56
+ schedule(at: Time.now + 30, key: "export-watchdog").watchdog
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def apply_export_result(effect_id:, arguments:, result:)
63
+ return unless effect_id == export_effect&.fetch("effect_id")
64
+ return unless arguments.fetch("revision") == revision
65
+ return if applied_effect_id == effect_id
66
+
67
+ self.artifact_key = result.fetch("artifact_key")
68
+ self.applied_effect_id = effect_id
69
+ end
70
+ end
71
+ ```
72
+
73
+ Register `build_report` through the ordinary effect registry. Its successful
74
+ result in this example is `{ "artifact_key" => "reports/example.pdf" }`.
75
+ The library builds the retirement payload, including `"outcome" => "retired"`;
76
+ the effect handler does not return that outcome itself. Ruby actor operations
77
+ receive keywords. `recover_export` needs handle/revision guards but no outcome
78
+ guard because only a new retirement invokes it. The same guarded result helper
79
+ handles success and completed-status repair, preventing duplicate application.
80
+ Only recovery emits a replacement; status observations never do.
81
+
82
+ The watchdog is optional: `on_recovery` alone enables automatic retirement.
83
+ `on_status` alone does not enable retirement, polling, or subscriptions. Explicit
84
+ checks require both bindings persisted by `emit` and cannot replace either.
85
+
86
+ ## Public envelopes and timeout
87
+
88
+ `SolidObjects::effect_handle` describes `{ "effect_id" => String }`.
89
+ `SolidObjects::effect_retired_payload[Arguments]` requires the effect ID, original
90
+ arguments, and `"outcome" => "retired"`. Status uses
91
+ `SolidObjects::effect_recovery_payload[Arguments, Result]`, a record union.
92
+ Every variant has an effect ID; retired and completed require original arguments;
93
+ only completed has a recorded result (including `nil`). Other Ruby observations
94
+ contain only the effect ID and outcome.
95
+
96
+ Strict packaged-consumer tests verify the constant literals and individual
97
+ records. Steep 2.0 does not narrow this string-keyed record union after comparing
98
+ `payload["outcome"]` with `COMPLETED`; accessing `result` through the union still
99
+ fails its return-type check. Use the concrete completed/retired record in typed
100
+ helpers after validating the discriminator, with an explicit type assertion if
101
+ needed. The library retains precise records rather than weakening them to an
102
+ untyped hash. Ordinary Ruby keyword dispatch needs no payload hydration.
103
+
104
+ | Frozen `SolidObjects::EffectRecoveryOutcome` constant | Wire value | Meaning |
105
+ | --- | --- | --- |
106
+ | `RETIRED` | `"retired"` | This check retired the effect; separate recovery owns replacement. |
107
+ | `DEFERRED` | `"deferred"` | Fresh owner; preserve its claim and attempts. |
108
+ | `PENDING` | `"pending"` | Initial execution or retry remains with the scheduler. |
109
+ | `COMPLETED` | `"completed"` | Original arguments and recorded result are available. |
110
+ | `DEAD` | `"dead"` | Preserve terminal failure and its existing callback. |
111
+ | `ALREADY_RETIRED` | `"already_retired"` | Earlier retirement; no additional recovery notification. |
112
+ | `MISSING` | `"missing"` | Owned binding exists but effect data was pruned. |
113
+
114
+ `recovery_timeout` must be positive finite seconds and requires `on_recovery`.
115
+ Fractional durations are allowed. Omission uses the current runtime
116
+ `process_alive_threshold`, normally 60 seconds. Smaller positive values are
117
+ floored at that runtime threshold; changing configuration changes the effective
118
+ floor even for existing effects. Database lookup errors surface as errors,
119
+ never as missing/stale observations.
120
+
121
+ Effect workers maintain their process heartbeat while the handler waits on
122
+ external I/O and while committing success or failure. A long-running healthy
123
+ handler therefore remains protected beyond the recovery timeout. This requires
124
+ an available database connection for the heartbeat, as well as runtime threads
125
+ that can continue running.
126
+
127
+ Failed updates emit `solid_objects.process.heartbeat_failed` and retry at the
128
+ configured heartbeat interval without consuming effect attempts. If an outage
129
+ lasts beyond the freshness window, recovery can still be permitted; retries do
130
+ not cancel external work or extend the configured window.
131
+
132
+ ## Compatibility and installation
133
+
134
+ Upgrade all effect workers and process cleanup roles before emitting effects
135
+ with recovery enabled. Older runtimes do not honor the persisted bindings or
136
+ the new lock protocol.
137
+
138
+ Run `solid_objects:install:migrations` and your application's normal migration
139
+ process before starting upgraded workers. The additive migration creates the
140
+ durable binding table; it does not change existing effect status constraints.
141
+
142
+ `emit` now returns its handle, including without recovery options. Callers may
143
+ ignore it. Wrappers must return `super`; operations whose last expression used
144
+ to be `emit` may now return the handle to callers. End those operations with
145
+ `nil` if their previous result must remain unchanged. The return-value change is
146
+ intentional and is not strictly backward compatible.
147
+
148
+ ## Transaction and lock protocol
149
+
150
+ Emission, actor state, the effect, and its recovery binding share the actor's
151
+ fenced commit. An explicit check executes on that same connection. Automatic
152
+ recovery performs one independent library transaction per candidate, with no
153
+ application transaction waiting on a second connection.
154
+
155
+ The lock order is originating instance, effect rows ordered by public effect
156
+ ID, recovery binding rows in the same order, then owner processes ordered by ID.
157
+ Completion and failure must acquire the instance before the effect. Pending
158
+ claims lock only their effect and do not subsequently acquire an instance lock.
159
+ Mailbox insertion reuses the instance lock already held by the decision.
160
+ Multiple checks in one actor commit lock all their effects and bindings before
161
+ locking any processes. Unlocked candidate reads are hints, never decisions.
162
+
163
+ Automatic passes prefilter owner freshness and the effective per-effect timeout
164
+ using database time, and visit at most `claim_scan_limit` stale candidates. Fresh
165
+ actors and owners are not locked, including owners protected by extended grace.
166
+ Remaining stale effects are revisited on later polls. Every candidate still
167
+ undergoes the authoritative locked recheck, and successful retirement announces
168
+ the committed mailbox work through the existing wake-up mechanism.
169
+
170
+ The decision samples database wall time after obtaining the owner lock. The
171
+ effective timeout is the larger of the runtime's `process_alive_threshold` and
172
+ the effect's persisted `recovery_timeout`, in seconds. A heartbeat newer than
173
+ the cutoff is fresh; equality is stale. A stopped or draining process with
174
+ fresh heartbeat evidence still protects an opted-in effect until that timeout.
175
+ Cleanup preserves opted-in claims, and process pruning excludes processes that
176
+ still own effects. Later effect polling and process cleanup revisit deferred
177
+ effects without resetting their last heartbeat.
178
+
179
+ Retirement stores a durable `retired_at` in `effect_recoveries` and moves the
180
+ effect into the existing terminal `completed` storage state, clearing its
181
+ claim. The recovery record distinguishes retirement from successful completion;
182
+ no success callback is generated. All recovery observations consult that record
183
+ before interpreting the effect row. A late completion or failure is rejected by
184
+ the existing processing/claim fence. This representation avoids rewriting the
185
+ existing effect-status constraint across adapters.
186
+
187
+ The retirement record and the recovery mailbox message commit atomically. A
188
+ winning explicit check additionally enqueues its status response after the
189
+ retirement notification. Failure to insert either message rolls back the whole
190
+ decision. Retirement is deduplicated per effect; check responses use separate
191
+ per-request idempotency keys. Wake-up signals are delivery hints after commit.
192
+
193
+ Recovery bindings survive effect/message pruning and remain until the originating
194
+ instance is destroyed or pruned. They do not prevent normal message or instance
195
+ retention. Within that lifetime, a removed non-retired effect reports `missing`
196
+ and a retirement record reports `already_retired`. A handle without an owned
197
+ binding raises an error, without disclosing another actor's state or recreating
198
+ a destroyed actor. Status-response message idempotency follows normal mailbox
199
+ retention; callers cannot supply or reuse internal check request IDs.
200
+
201
+ An owner heartbeat measures process liveness, not effect progress. Retirement
202
+ does not cancel the old handler or prove a remote request stopped. External
203
+ actions still require idempotency across retries and replacement generations.
data/docs/operations.md CHANGED
@@ -69,6 +69,12 @@ Rails schema migrations and actor state migrations are separate concerns.
69
69
 
70
70
  ### Host application tooling
71
71
 
72
+ RBS/Steep applications can generate
73
+ [actor-specific dispatch signatures](development.md#actor-specific-dispatch-signatures)
74
+ for reminders, transmit calls, and effect callback names. This is optional development
75
+ tooling; generated signatures do not change runtime dispatch. The generator does
76
+ not supply equivalent Sorbet/Tapioca actor-specific types.
77
+
72
78
  Installed engine migrations are copied as
73
79
  `db/migrate/*_create_solid_objects_tables.solid_objects.rb`. If the host enables
74
80
  `Rails/CreateTableWithTimestamps`, exclude engine-owned migrations rather than
data/docs/reminders.md CHANGED
@@ -39,6 +39,14 @@ raises, and nothing is logged except a `solid_objects.reminder.replaced` event.
39
39
 
40
40
  ## An alarm per item, with `key:`
41
41
 
42
+ For watchdogs paired with an effect's give-up callback, see
43
+ [typing your `on_failure` handler](architecture.md#typing-your-on_failure-handler)
44
+ to retain the original argument types without repeating the error hash contract.
45
+
46
+ For static checking of watchdog operation names and keyword arguments, opt into
47
+ [actor-specific RBS signatures](development.md#actor-specific-dispatch-signatures).
48
+ The Ruby `schedule(...).recover_if_stuck(generation: ...)` syntax stays the same.
49
+
42
50
  Pass `key:` when an actor is waiting on several things at once. The key is your
43
51
  own identifier for the item, and it names that item's alarm, so each item gets
44
52
  one:
data/docs/roadmap.md CHANGED
@@ -14,6 +14,13 @@
14
14
  dead letters, and tail retry
15
15
  - Transactional effects with success/failure actor messages carrying the
16
16
  originally staged arguments for callback correlation
17
+ - Stable `emit` handles and opted-in abandoned effect retirement coordinated
18
+ with effect claims, with atomic recovery/status mailbox notifications and
19
+ per-effect extending heartbeat timeouts and heartbeats throughout long-running
20
+ effect handlers. See [effect recovery](effect-recovery.md)
21
+ for the SQL lock protocol, retention boundary, and external idempotency limit.
22
+ - Public RBS effect success/failure envelopes and error records, checked against
23
+ the runtime constructors and a packaged consumer with strict Steep diagnostics
17
24
  - Actor-to-actor asynchronous outbox delivery. Effects and broadcasts use
18
25
  portable status rows with polling indexes and database check constraints on
19
26
  status, which works on all three adapters; a future version may add narrow
@@ -65,6 +72,10 @@
65
72
  gem's dependencies
66
73
  - Inline RBS generation/validation, Steep, Standard Ruby, Solid Queue's exact
67
74
  RuboCop policy, and a warning-free Brakeman scan
75
+ - Opt-in actor-specific RBS generation for schedule/transmit keyword arguments
76
+ and effect callback names, using application declarations and checked consumer
77
+ fixtures. Dynamic names remain an explicit escape hatch; complete reference
78
+ typing and actor-specific RBI generation are separate work
68
79
  - Compatibility CI across the supported span: Ruby 3.3, 3.4, and 4.0 against
69
80
  Rails 7.1, 7.2, 8.0, and 8.1, pinned through `RAILS_VERSION` so the advertised
70
81
  range is verified rather than assumed. The compatibility job runs SQLite only;
@@ -23,7 +23,7 @@ module AtLeastOnceBoot
23
23
  require "solid_objects/database_adapter"
24
24
  %w[
25
25
  record process instance message ready_message claimed_message
26
- reminder effect broadcast dead_letter
26
+ reminder effect effect_recovery broadcast dead_letter
27
27
  ].each { |model| require File.join(ROOT, "app/models/solid_objects", model) }
28
28
 
29
29
  SolidObjects.configuration.authorize_message = ->(**) { true }
@@ -40,8 +40,10 @@ module AtLeastOnceBoot
40
40
  require File.join(ROOT, "db/migrate/20260805000000_create_solid_objects_tables")
41
41
  require File.join(ROOT, "db/migrate/20260806000000_add_state_revision_to_solid_objects_instances")
42
42
  require File.join(ROOT, "db/migrate/20260813000000_rename_message_dispatch_columns")
43
+ require File.join(ROOT, "db/migrate/20260915000000_add_solid_objects_effect_recoveries")
43
44
  CreateSolidObjectsTables.new.migrate(:up)
44
45
  AddStateRevisionToSolidObjectsInstances.new.migrate(:up)
45
46
  RenameMessageDispatchColumns.new.migrate(:up)
47
+ AddSolidObjectsEffectRecoveries.new.migrate(:up)
46
48
  end
47
49
  end