solid_objects 0.14.7 → 0.15.1

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 (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +20 -0
  3. data/README.md +1 -0
  4. data/app/models/solid_objects/effect_recovery.rb +10 -0
  5. data/benchmark/support.rb +3 -0
  6. data/db/migrate/20260915000000_add_solid_objects_effect_recoveries.rb +17 -0
  7. data/docs/effect-recovery.md +203 -0
  8. data/docs/roadmap.md +5 -0
  9. data/examples/at_least_once/boot.rb +3 -1
  10. data/lib/solid_objects/actor.rb +43 -4
  11. data/lib/solid_objects/actor_signatures.rb +1 -1
  12. data/lib/solid_objects/database_adapter.rb +13 -0
  13. data/lib/solid_objects/doctor.rb +1 -0
  14. data/lib/solid_objects/effect_executor.rb +7 -0
  15. data/lib/solid_objects/effect_payload.rb +15 -0
  16. data/lib/solid_objects/effect_recovery_coordinator.rb +143 -0
  17. data/lib/solid_objects/executor.rb +14 -2
  18. data/lib/solid_objects/instance_pruner.rb +2 -1
  19. data/lib/solid_objects/process_heartbeat.rb +64 -0
  20. data/lib/solid_objects/process_pruner.rb +1 -1
  21. data/lib/solid_objects/process_registry.rb +9 -6
  22. data/lib/solid_objects/test_helper.rb +1 -0
  23. data/lib/solid_objects/version.rb +1 -1
  24. data/lib/solid_objects.rb +2 -0
  25. data/sig/generated/lib/solid_objects/actor.rbs +40 -6
  26. data/sig/generated/lib/solid_objects/database_adapter.rbs +3 -0
  27. data/sig/generated/lib/solid_objects/effect_payload.rbs +9 -0
  28. data/sig/generated/lib/solid_objects/effect_recovery_coordinator.rbs +41 -0
  29. data/sig/generated/lib/solid_objects/process_heartbeat.rbs +38 -0
  30. data/sig/generated/models/solid_objects/effect_recovery.rbs +6 -0
  31. data/sig/public/effect_payload.rbs +14 -0
  32. metadata +10 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d36e77947a8de94216c9174cc3a34a878803bdf242efa0b10f6067d48b4e7cda
4
- data.tar.gz: 178955e77093552df5569ee5c0badfb6035a51a479c7bcfbd1025465ac5947a4
3
+ metadata.gz: b9179610a28d7c335fbffb11a9a86009c15ae4b1e7e2e1a4f638a69c64085b85
4
+ data.tar.gz: bc67c66ad2f7841eb32724d5d8cbcb744250fb4f0579c7fdb6546e1a9b259fd5
5
5
  SHA512:
6
- metadata.gz: 491698862debab8266650dbdc21a86561d3fa7dd51764edd469a7b5173a1b2a24a2542d39a584796d3433a2bc66794d7d5251ebfe73d7c991d539f2a4c92d287
7
- data.tar.gz: 705ce841595e49cbc892535afd15fb96b8ac4134bb01a1c92de8d12061a6fe5717b745d989decf82fb1c25a19966b9e50a88afb30a1426c23055e1333c00f2cc
6
+ metadata.gz: 9786fdf1d29ff91f145d47f5a5523d6371ebe3bd1623af0fde9652c4327e52c12e90f540e5d9623e5e79ca616656c580759830e31577bd0d0a88335a15a1ddd7
7
+ data.tar.gz: 3b1513d225edc9f56e7063e9517d9abe7f41b3e2b59e37c93fb269d0b63a9e6963e7442d3e5033361a9ba7aeb0f39859d0bd4e911be1f51e7d77a0940e999a85
data/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.15.1 - 2026-09-16
4
+
5
+ - Use the existing cleanup index when finding expired actor instances. Preserve
6
+ creation-time fallback for never-used instances and the strict retention cutoff.
7
+
8
+ ## 0.15.0 - 2026-09-15
9
+
10
+ - Maintain effect-owner heartbeats during long-running handlers, completion, and
11
+ failure handling, so healthy external I/O cannot trigger abandoned recovery.
12
+ Report failed heartbeat updates and retry on the next configured interval
13
+ without consuming effect attempts.
14
+ - Return a stable effect handle from every `emit`. Wrappers must return it;
15
+ operations relying on an implicit `nil` result should return `nil` explicitly.
16
+ - Add abandoned effect recovery with `on_recovery`, optional `on_status`, staged
17
+ `request_effect_recovery`, and an extending `recovery_timeout` in seconds.
18
+ Retirement and durable callbacks share the claim-locking transaction. Add
19
+ frozen outcome constants and public RBS envelopes. Install the new recovery
20
+ binding migration before upgrading runtime processes. External actions still
21
+ require idempotency; retirement does not cancel an old handler or remote call.
22
+
3
23
  ## 0.14.7 - 2026-09-14
4
24
 
5
25
  - Publish RBS contracts for effect callback envelopes and Ruby error summaries.
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.
@@ -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
@@ -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/roadmap.md CHANGED
@@ -14,6 +14,11 @@
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.
17
22
  - Public RBS effect success/failure envelopes and error records, checked against
18
23
  the runtime constructors and a packaged consumer with strict Steep diagnostics
19
24
  - Actor-to-actor asynchronous outbox delivery. Effects and broadcasts use
@@ -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
@@ -2,7 +2,9 @@
2
2
 
3
3
  module SolidObjects
4
4
  class Actor
5
- EffectIntent = Data.define(:name, :arguments, :success_operation, :failure_operation)
5
+ EffectIntent = Data.define(:effect_id, :name, :arguments, :success_operation, :failure_operation,
6
+ :recovery_operation, :status_operation, :recovery_timeout)
7
+ EffectRecoveryIntent = Data.define(:effect_id, :request_id)
6
8
  CommitActionIntent = Data.define(:name, :arguments)
7
9
  # The reminders table holds a name in 191 characters.
8
10
  REMINDER_NAME_LIMIT = 191
@@ -143,6 +145,7 @@ module SolidObjects
143
145
  # @rbs @actor_id: String
144
146
  # @rbs @state: State
145
147
  # @rbs @effect_intents: Array[EffectIntent]
148
+ # @rbs @effect_recovery_intents: Array[EffectRecoveryIntent]
146
149
  # @rbs @commit_action_intents: Array[CommitActionIntent]
147
150
  # @rbs @reminder_intents: Array[ReminderIntent]
148
151
  # @rbs @outbound_message_intents: Array[OutboundMessageIntent]
@@ -154,6 +157,7 @@ module SolidObjects
154
157
  @actor_id = actor_id
155
158
  @state = state
156
159
  @effect_intents = []
160
+ @effect_recovery_intents = []
157
161
  @commit_action_intents = []
158
162
  @reminder_intents = []
159
163
  @outbound_message_intents = []
@@ -175,18 +179,36 @@ module SolidObjects
175
179
  raise Rejected.new(code: rejection_code, message:, details:)
176
180
  end
177
181
 
178
- # @rbs (Symbol | String, ?on_success: Symbol | String?, ?on_failure: Symbol | String?, **untyped) -> nil
179
- def emit(name, on_success: nil, on_failure: nil, **arguments)
182
+ # @rbs (Symbol | String, ?on_success: Symbol | String?, ?on_failure: Symbol | String?, ?on_recovery: Symbol | String?, ?on_status: Symbol | String?, ?recovery_timeout: Numeric?, **untyped) -> effect_handle
183
+ def emit(name, on_success: nil, on_failure: nil, on_recovery: nil, on_status: nil, recovery_timeout: nil, **arguments)
180
184
  validate_effect_callback!(on_success)
181
185
  validate_effect_callback!(on_failure)
186
+ validate_effect_callback!(on_recovery)
187
+ validate_effect_callback!(on_status)
188
+ validate_recovery_timeout!(timeout: recovery_timeout, operation: on_recovery)
189
+ effect_id = SecureRandom.uuid
182
190
  EffectIntent.new(
191
+ effect_id:,
183
192
  name: name.to_s,
184
193
  arguments: Serialization.dump(arguments),
185
194
  success_operation: on_success&.to_s,
186
- failure_operation: on_failure&.to_s
195
+ failure_operation: on_failure&.to_s,
196
+ recovery_operation: on_recovery&.to_s,
197
+ status_operation: on_status&.to_s,
198
+ recovery_timeout: recovery_timeout&.to_f
187
199
  ).tap do |intent|
188
200
  effect_intents << intent
189
201
  end
202
+ { "effect_id" => effect_id }
203
+ end
204
+
205
+ # @rbs (effect_handle) -> nil
206
+ def request_effect_recovery(handle)
207
+ unless handle.is_a?(Hash) && handle["effect_id"].is_a?(String) && !handle.fetch("effect_id").empty?
208
+ raise InvalidPayload, "expected an effect handle returned by emit"
209
+ end
210
+
211
+ effect_recovery_intents << EffectRecoveryIntent.new(effect_id: handle.fetch("effect_id"), request_id: SecureRandom.uuid)
190
212
  nil
191
213
  end
192
214
 
@@ -360,6 +382,11 @@ module SolidObjects
360
382
  effect_intents.shift(effect_intents.length)
361
383
  end
362
384
 
385
+ # @rbs () -> Array[EffectRecoveryIntent]
386
+ def drain_effect_recovery_intents
387
+ effect_recovery_intents.shift(effect_recovery_intents.length)
388
+ end
389
+
363
390
  # @rbs () -> Array[CommitActionIntent]
364
391
  def drain_commit_action_intents
365
392
  commit_action_intents.shift(commit_action_intents.length)
@@ -378,6 +405,7 @@ module SolidObjects
378
405
  # @rbs () -> void
379
406
  def discard_intents
380
407
  effect_intents.clear
408
+ effect_recovery_intents.clear
381
409
  commit_action_intents.clear
382
410
  reminder_intents.clear
383
411
  outbound_message_intents.clear
@@ -386,6 +414,7 @@ module SolidObjects
386
414
  private
387
415
 
388
416
  attr_reader :effect_intents,
417
+ :effect_recovery_intents,
389
418
  :commit_action_intents,
390
419
  :reminder_intents,
391
420
  :outbound_message_intents
@@ -412,5 +441,15 @@ module SolidObjects
412
441
 
413
442
  raise UnknownMessage, "unknown effect callback operation #{operation.inspect}"
414
443
  end
444
+
445
+ # @rbs (timeout: Numeric?, operation: String | Symbol?) -> void
446
+ def validate_recovery_timeout!(timeout:, operation:)
447
+ return if timeout.nil?
448
+
449
+ unless timeout.is_a?(Numeric) && timeout.real? && timeout.to_f.finite? && timeout.to_f.positive?
450
+ raise ArgumentError, "recovery_timeout must be a positive finite duration in seconds"
451
+ end
452
+ raise ArgumentError, "recovery_timeout requires on_recovery" unless operation
453
+ end
415
454
  end
416
455
  end
@@ -60,7 +60,7 @@ module SolidObjects
60
60
 
61
61
  def schedule: (at: Time, ?every: Numeric?, ?missed: Symbol | String, ?key: (String | Symbol | Integer)?) -> #{name}::_SolidObjectsOperations
62
62
  def transmit: () -> #{name}::_SolidObjectsOperations
63
- def emit: (Symbol | String, ?on_success: (#{callbacks}), ?on_failure: (#{callbacks}), **untyped) -> nil
63
+ def emit: (Symbol | String, ?on_success: (#{callbacks}), ?on_failure: (#{callbacks}), ?on_recovery: (#{callbacks}), ?on_status: (#{callbacks}), ?recovery_timeout: Numeric?, **untyped) -> SolidObjects::effect_handle
64
64
  end
65
65
  RBS
66
66
  end
@@ -111,6 +111,19 @@ module SolidObjects
111
111
  ActiveSupport::IsolatedExecutionState[TRANSACTION_CLOCK] ||= read_database_now
112
112
  end
113
113
 
114
+ # @rbs () -> Time
115
+ def database_clock_now
116
+ value = with_connection do |connection|
117
+ expression = case self.class.family(connection)
118
+ when :postgresql then "clock_timestamp()"
119
+ when :mysql then "CURRENT_TIMESTAMP(6)"
120
+ else "STRFTIME('%Y-%m-%d %H:%M:%f', 'now')"
121
+ end
122
+ connection.select_value("SELECT #{expression}")
123
+ end
124
+ value.is_a?(Time) ? value.utc : Time.parse("#{value} UTC").utc
125
+ end
126
+
114
127
  # @rbs () { () -> untyped } -> untyped
115
128
  def with_lock_retry
116
129
  yield
@@ -78,6 +78,7 @@ module SolidObjects
78
78
  ],
79
79
  reminders: %w[id instance_id operation next_run_at status],
80
80
  effects: %w[id message_id instance_id effect_id status available_at],
81
+ effect_recoveries: %w[effect_id instance_id recovery_operation status_operation recovery_timeout retired_at],
81
82
  broadcasts: %w[id message_id instance_id broadcast_id status available_at],
82
83
  dead_letters: %w[id message_id instance_id actor_type actor_id attempts]
83
84
  }.freeze
@@ -45,12 +45,16 @@ module SolidObjects
45
45
  effect = claim_next
46
46
  return false unless effect
47
47
 
48
+ heartbeat = ProcessHeartbeat.new(process_registry:)
49
+ heartbeat.start
48
50
  result = deliver(effect)
49
51
  complete(effect, result)
50
52
  true
51
53
  rescue => error
52
54
  fail_effect(effect, error) if effect
53
55
  false
56
+ ensure
57
+ heartbeat&.stop
54
58
  end
55
59
 
56
60
  # @rbs () -> void
@@ -113,6 +117,7 @@ module SolidObjects
113
117
 
114
118
  # @rbs () -> Effect?
115
119
  def claim_next
120
+ EffectRecoveryCoordinator.new.recover_available
116
121
  database_adapter.transaction do
117
122
  now = database_adapter.database_now
118
123
  effect = database_adapter.lock_candidates(
@@ -178,6 +183,7 @@ module SolidObjects
178
183
  )
179
184
  result_message = nil
180
185
  database_adapter.transaction do
186
+ Instance.lock.find(effect.instance_id)
181
187
  locked_effect = Effect.lock.find(effect.id)
182
188
  verify_claim!(locked_effect)
183
189
  result_message = enqueue_result_message(
@@ -213,6 +219,7 @@ module SolidObjects
213
219
  def fail_effect(effect, error)
214
220
  result_message = nil
215
221
  database_adapter.transaction do
222
+ Instance.lock.find(effect.instance_id)
216
223
  locked_effect = Effect.lock.find(effect.id)
217
224
  verify_claim!(locked_effect)
218
225
  dead = locked_effect.attempt_count >= locked_effect.max_attempts
@@ -3,6 +3,21 @@
3
3
  module SolidObjects
4
4
  module EffectPayload
5
5
  class << self
6
+ # @rbs [Arguments] (effect_id: String, arguments: Arguments) -> effect_retired_payload[Arguments]
7
+ def retired(effect_id:, arguments:)
8
+ { "effect_id" => effect_id, "arguments" => arguments, "outcome" => EffectRecoveryOutcome::RETIRED }
9
+ end
10
+
11
+ # @rbs [Arguments, Result] (effect_id: String, arguments: Arguments, result: Result) -> effect_completed_recovery_payload[Arguments, Result]
12
+ def recovery_completed(effect_id:, arguments:, result:)
13
+ { "effect_id" => effect_id, "arguments" => arguments, "outcome" => EffectRecoveryOutcome::COMPLETED, "result" => result }
14
+ end
15
+
16
+ # @rbs (effect_id: String, outcome: effect_observation_outcome) -> effect_observation_payload
17
+ def recovery_observation(effect_id:, outcome:)
18
+ { "effect_id" => effect_id, "outcome" => outcome }
19
+ end
20
+
6
21
  # @rbs [Arguments, Result] (effect_id: String, arguments: Arguments, result: Result) -> effect_success_payload[Arguments, Result]
7
22
  def success(effect_id:, arguments:, result:)
8
23
  { "effect_id" => effect_id, "arguments" => arguments, "result" => result }
@@ -0,0 +1,143 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ module EffectRecoveryOutcome
5
+ RETIRED = "retired".freeze #: "retired"
6
+ DEFERRED = "deferred".freeze #: "deferred"
7
+ PENDING = "pending".freeze #: "pending"
8
+ COMPLETED = "completed".freeze #: "completed"
9
+ DEAD = "dead".freeze #: "dead"
10
+ ALREADY_RETIRED = "already_retired".freeze #: "already_retired"
11
+ MISSING = "missing".freeze #: "missing"
12
+ end
13
+
14
+ class EffectRecoveryCoordinator
15
+ # @rbs (instance: Instance, intents: Array[Actor::EffectRecoveryIntent]) -> void
16
+ def check(instance:, intents:)
17
+ return if intents.empty?
18
+
19
+ effect_ids = intents.map(&:effect_id).uniq.sort
20
+ effects = Effect.where(instance_id: instance.id, effect_id: effect_ids).order(:effect_id).lock.to_a.index_by(&:effect_id)
21
+ recoveries = EffectRecovery.where(instance_id: instance.id, effect_id: effect_ids).order(:effect_id).lock.to_a.index_by(&:effect_id)
22
+ effect_ids.each do |effect_id|
23
+ recovery = recoveries[effect_id]
24
+ unless recovery&.recovery_operation && recovery.status_operation
25
+ raise InvalidPayload, "effect recovery requires an owned handle with on_recovery and on_status"
26
+ end
27
+ end
28
+ owner_ids = effects.values.filter_map(&:claimed_by).uniq.sort
29
+ owners = Process.where(id: owner_ids).order(:id).lock.to_a.index_by(&:id)
30
+ now = SolidObjects.database_adapter.database_clock_now
31
+ intents.each do |intent|
32
+ check_one(instance:, intent:, recovery: recoveries.fetch(intent.effect_id), effect: effects[intent.effect_id], owners:, now:)
33
+ end
34
+ end
35
+
36
+ # @rbs () -> void
37
+ def recover_available
38
+ recovery_candidates.each do |candidate|
39
+ notification = SolidObjects.database_adapter.transaction do
40
+ instance = Instance.lock.find_by(id: candidate.instance_id)
41
+ next unless instance
42
+
43
+ effect = Effect.lock.find_by(effect_id: candidate.effect_id, instance_id: instance.id)
44
+ recovery = EffectRecovery.lock.find_by(effect_id: candidate.effect_id, instance_id: instance.id)
45
+ next unless effect && recovery
46
+ next if recovery.retired_at || effect.status != "processing"
47
+
48
+ owner = Process.lock.find_by(id: effect.claimed_by) if effect.claimed_by
49
+ now = SolidObjects.database_adapter.database_clock_now
50
+ timeout = [ SolidObjects.configuration.process_alive_threshold, recovery.recovery_timeout || 0 ].max
51
+ next if owner && owner.last_heartbeat_at > now - timeout
52
+
53
+ retire(instance:, effect:, recovery:, now:)
54
+ end
55
+ Mailbox.new.announce(notification) if notification
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ # @rbs () -> ActiveRecord::Relation[EffectRecovery]
62
+ def recovery_candidates
63
+ effects = Effect.table_name
64
+ owners = Process.table_name
65
+ bindings = EffectRecovery.table_name
66
+ heartbeat = case DatabaseAdapter.family(Record.connection)
67
+ when :postgresql then "EXTRACT(EPOCH FROM #{owners}.last_heartbeat_at)"
68
+ when :mysql then "UNIX_TIMESTAMP(#{owners}.last_heartbeat_at)"
69
+ else "CAST(STRFTIME('%s', #{owners}.last_heartbeat_at) AS REAL)"
70
+ end
71
+ now = SolidObjects.database_adapter.database_clock_now.to_f
72
+ threshold = SolidObjects.configuration.process_alive_threshold
73
+ EffectRecovery.joins("INNER JOIN #{effects} ON #{effects}.effect_id = #{bindings}.effect_id")
74
+ .joins("LEFT JOIN #{owners} ON #{owners}.id = #{effects}.claimed_by")
75
+ .where(retired_at: nil).where.not(recovery_operation: nil)
76
+ .where("#{effects}.status = ?", "processing")
77
+ .where("#{owners}.id IS NULL OR #{heartbeat} <= ? - CASE WHEN #{bindings}.recovery_timeout > ? THEN #{bindings}.recovery_timeout ELSE ? END", now, threshold, threshold)
78
+ .order(:effect_id).limit(SolidObjects.configuration.claim_scan_limit)
79
+ end
80
+
81
+ # @rbs (instance: Instance, intent: Actor::EffectRecoveryIntent, recovery: EffectRecovery, effect: Effect?, owners: Hash[String, Process], now: Time) -> void
82
+ def check_one(instance:, intent:, recovery:, effect:, owners:, now:)
83
+ key = "effect:#{intent.effect_id}:check:#{intent.request_id}"
84
+ return if Message.where(instance_id: instance.id, idempotency_key: key).exists?
85
+
86
+ outcome = observe(effect:, recovery:, owners:, now:)
87
+ retire(instance:, effect:, recovery:, now:) if outcome == EffectRecoveryOutcome::RETIRED
88
+ operation = recovery.status_operation
89
+ unless operation && SolidObjects.registry.fetch(instance.actor_type).definition.messages.key?(operation.to_sym)
90
+ raise UnknownMessage, "unknown effect status operation #{operation.inspect}"
91
+ end
92
+ arguments = case outcome
93
+ when EffectRecoveryOutcome::RETIRED
94
+ EffectPayload.retired(effect_id: intent.effect_id, arguments: effect.arguments)
95
+ when EffectRecoveryOutcome::COMPLETED
96
+ EffectPayload.recovery_completed(effect_id: intent.effect_id, arguments: effect.arguments, result: effect.result)
97
+ else
98
+ EffectPayload.recovery_observation(effect_id: intent.effect_id, outcome:)
99
+ end
100
+ Mailbox.new.enqueue_in_transaction(
101
+ reference: Reference.new(actor_type: instance.actor_type, actor_id: instance.actor_id),
102
+ operation:,
103
+ arguments:,
104
+ delivery_mode: "internal",
105
+ idempotency_key: key
106
+ )
107
+ end
108
+
109
+ # @rbs (effect: Effect?, recovery: EffectRecovery, owners: Hash[String, Process], now: Time) -> String
110
+ def observe(effect:, recovery:, owners:, now:)
111
+ return EffectRecoveryOutcome::ALREADY_RETIRED if recovery.retired_at
112
+ return EffectRecoveryOutcome::MISSING unless effect
113
+ return EffectRecoveryOutcome::PENDING if effect.status == "pending"
114
+ return EffectRecoveryOutcome::COMPLETED if effect.status == "completed"
115
+ return EffectRecoveryOutcome::DEAD if effect.status == "dead"
116
+
117
+ owner = owners[effect.claimed_by]
118
+ timeout = [ SolidObjects.configuration.process_alive_threshold, recovery.recovery_timeout || 0 ].max
119
+ return EffectRecoveryOutcome::DEFERRED if owner && owner.last_heartbeat_at > now - timeout
120
+
121
+ EffectRecoveryOutcome::RETIRED
122
+ end
123
+
124
+ # @rbs (instance: Instance, effect: Effect, recovery: EffectRecovery, now: Time) -> Message
125
+ def retire(instance:, effect:, recovery:, now:)
126
+ actor_class = SolidObjects.registry.fetch(instance.actor_type)
127
+ operation = recovery.recovery_operation
128
+ unless operation && actor_class.definition.messages.key?(operation.to_sym)
129
+ raise UnknownMessage, "unknown effect recovery operation #{operation.inspect}"
130
+ end
131
+
132
+ effect.update!(status: "completed", completed_at: now, claimed_by: nil, claimed_at: nil)
133
+ recovery.update!(retired_at: now)
134
+ Mailbox.new.enqueue_in_transaction(
135
+ reference: Reference.new(actor_type: instance.actor_type, actor_id: instance.actor_id),
136
+ operation:,
137
+ arguments: EffectPayload.retired(effect_id: effect.effect_id, arguments: effect.arguments),
138
+ delivery_mode: "internal",
139
+ idempotency_key: "effect:#{effect.effect_id}:recovery"
140
+ )
141
+ end
142
+ end
143
+ end
@@ -90,6 +90,7 @@ module SolidObjects
90
90
  max_bytes: SolidObjects.configuration.max_result_bytes
91
91
  )
92
92
  effect_intents = actor.drain_effect_intents
93
+ recovery_intents = actor.drain_effect_recovery_intents
93
94
  commit_action_intents = actor.drain_commit_action_intents
94
95
  reminder_intents = actor.drain_reminder_intents
95
96
  outbound_message_intents = actor.drain_outbound_message_intents
@@ -130,6 +131,7 @@ module SolidObjects
130
131
  observable_changes:,
131
132
  state_changed:
132
133
  )
134
+ EffectRecoveryCoordinator.new.check(instance:, intents: recovery_intents)
133
135
  claimed_message.destroy!
134
136
  end
135
137
 
@@ -241,10 +243,10 @@ module SolidObjects
241
243
  # @rbs (message: Message, instance: Instance, intents: Array[Actor::EffectIntent]) -> Array[Effect]
242
244
  def enqueue_effects(message:, instance:, intents:)
243
245
  intents.map do |intent|
244
- Effect.create!(
246
+ effect = Effect.create!(
245
247
  message:,
246
248
  instance:,
247
- effect_id: SecureRandom.uuid,
249
+ effect_id: intent.effect_id,
248
250
  name: intent.name,
249
251
  arguments: intent.arguments,
250
252
  success_operation: intent.success_operation,
@@ -253,6 +255,16 @@ module SolidObjects
253
255
  max_attempts: SolidObjects.configuration.max_attempts,
254
256
  available_at: SolidObjects.database_adapter.database_now
255
257
  )
258
+ if intent.recovery_operation || intent.status_operation
259
+ EffectRecovery.create!(
260
+ effect_id: intent.effect_id,
261
+ instance:,
262
+ recovery_operation: intent.recovery_operation,
263
+ status_operation: intent.status_operation,
264
+ recovery_timeout: intent.recovery_timeout
265
+ )
266
+ end
267
+ effect
256
268
  end
257
269
  end
258
270
 
@@ -33,9 +33,10 @@ module SolidObjects
33
33
  # @rbs () -> Array[ActiveRecord::Relation[Instance]]
34
34
  def policy_relations
35
35
  SolidObjects.configuration.instance_retention_by_actor_type.map do |actor_type, retention|
36
+ cutoff = now - retention
36
37
  prunable
37
38
  .where(actor_type: actor_type.to_s)
38
- .where("COALESCE(last_used_at, created_at) < ?", now - retention)
39
+ .where("last_used_at < ? OR (last_used_at IS NULL AND created_at < ?)", cutoff, cutoff)
39
40
  end
40
41
  end
41
42
 
@@ -0,0 +1,64 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ class ProcessHeartbeat
5
+ # @rbs @process_registry: ProcessRegistry
6
+ # @rbs @mutex: Thread::Mutex
7
+ # @rbs @condition: Thread::ConditionVariable
8
+ # @rbs @stopped: bool
9
+ # @rbs @thread: Thread?
10
+
11
+ # @rbs (process_registry: ProcessRegistry) -> void
12
+ def initialize(process_registry:)
13
+ @process_registry = process_registry
14
+ @mutex = Thread::Mutex.new
15
+ @condition = Thread::ConditionVariable.new
16
+ @stopped = false
17
+ @thread = nil
18
+ end
19
+
20
+ # @rbs () -> void
21
+ def start
22
+ @thread = Thread.new do
23
+ Thread.current.report_on_exception = false
24
+ loop do
25
+ break if wait_for_interval
26
+
27
+ Record.connection_pool.with_connection { process_registry.heartbeat }
28
+ rescue => error
29
+ report_failure(error)
30
+ end
31
+ end
32
+ end
33
+
34
+ # @rbs () -> void
35
+ def stop
36
+ mutex.synchronize do
37
+ @stopped = true
38
+ condition.broadcast
39
+ end
40
+ @thread&.join
41
+ end
42
+
43
+ private
44
+
45
+ attr_reader :process_registry, :mutex, :condition
46
+
47
+ # @rbs (Exception) -> void
48
+ def report_failure(error)
49
+ payload = { process_id: process_registry.process_record&.id, error_class: error.class.name }
50
+ SolidObjects.configuration.logger.warn({ event: "solid_objects.process.heartbeat_failed", **payload })
51
+ SolidObjects.instrument(:"process.heartbeat_failed", **payload)
52
+ rescue
53
+ nil
54
+ end
55
+
56
+ # @rbs () -> bool
57
+ def wait_for_interval
58
+ mutex.synchronize do
59
+ condition.wait(mutex, SolidObjects.configuration.process_heartbeat_interval) unless @stopped
60
+ @stopped
61
+ end
62
+ end
63
+ end
64
+ end
@@ -43,7 +43,7 @@ module SolidObjects
43
43
  Process.where(
44
44
  shutdown_state: "stopped",
45
45
  stopped_at: ...(now - SolidObjects.configuration.process_retention)
46
- )
46
+ ).where.not(id: Effect.where.not(claimed_by: nil).select(:claimed_by))
47
47
  end
48
48
  end
49
49
  end
@@ -10,6 +10,7 @@ module SolidObjects
10
10
  class << self
11
11
  # @rbs (?now: Time) -> Integer
12
12
  def cleanup_dead(now: SolidObjects.database_adapter.database_now)
13
+ EffectRecoveryCoordinator.new.recover_available
13
14
  stale_at = now - SolidObjects.configuration.process_alive_threshold
14
15
  dead_processes = Process
15
16
  .where.not(shutdown_state: "stopped")
@@ -32,12 +33,14 @@ module SolidObjects
32
33
  process_id: nil,
33
34
  activation_token: nil
34
35
  )
35
- Effect.where(claimed_by: process_record.id).update_all(
36
- status: "pending",
37
- claimed_by: nil,
38
- claimed_at: nil,
39
- available_at: now
40
- )
36
+ Effect.where(claimed_by: process_record.id)
37
+ .where.not(effect_id: EffectRecovery.where.not(recovery_operation: nil).select(:effect_id))
38
+ .update_all(
39
+ status: "pending",
40
+ claimed_by: nil,
41
+ claimed_at: nil,
42
+ available_at: now
43
+ )
41
44
  Reminder.where(claimed_by: process_record.id).update_all(
42
45
  claimed_by: nil,
43
46
  claimed_at: nil
@@ -35,6 +35,7 @@ module SolidObjects
35
35
  ClaimedMessage,
36
36
  ReadyMessage,
37
37
  Broadcast,
38
+ EffectRecovery,
38
39
  Effect,
39
40
  Reminder,
40
41
  Message,
@@ -1,5 +1,5 @@
1
1
  # rbs_inline: enabled
2
2
 
3
3
  module SolidObjects
4
- VERSION = "0.14.7"
4
+ VERSION = "0.15.1"
5
5
  end
data/lib/solid_objects.rb CHANGED
@@ -56,6 +56,8 @@ require "solid_objects/wake_up_adapters"
56
56
  require "solid_objects/polling_backoff"
57
57
  require "solid_objects/effect_registry"
58
58
  require "solid_objects/effect_payload"
59
+ require "solid_objects/effect_recovery_coordinator"
60
+ require "solid_objects/process_heartbeat"
59
61
  require "solid_objects/commit_action_registry"
60
62
  require "solid_objects/lease"
61
63
  require "solid_objects/lease_renewer"
@@ -3,6 +3,8 @@
3
3
  module SolidObjects
4
4
  class Actor
5
5
  class EffectIntent < Data
6
+ attr_reader effect_id(): untyped
7
+
6
8
  attr_reader name(): untyped
7
9
 
8
10
  attr_reader arguments(): untyped
@@ -11,12 +13,31 @@ module SolidObjects
11
13
 
12
14
  attr_reader failure_operation(): untyped
13
15
 
14
- def self.new: (untyped name, untyped arguments, untyped success_operation, untyped failure_operation) -> instance
15
- | (name: untyped, arguments: untyped, success_operation: untyped, failure_operation: untyped) -> instance
16
+ attr_reader recovery_operation(): untyped
17
+
18
+ attr_reader status_operation(): untyped
19
+
20
+ attr_reader recovery_timeout(): untyped
21
+
22
+ def self.new: (untyped effect_id, untyped name, untyped arguments, untyped success_operation, untyped failure_operation, untyped recovery_operation, untyped status_operation, untyped recovery_timeout) -> instance
23
+ | (effect_id: untyped, name: untyped, arguments: untyped, success_operation: untyped, failure_operation: untyped, recovery_operation: untyped, status_operation: untyped, recovery_timeout: untyped) -> instance
16
24
 
17
- def self.members: () -> [ :name, :arguments, :success_operation, :failure_operation ]
25
+ def self.members: () -> [ :effect_id, :name, :arguments, :success_operation, :failure_operation, :recovery_operation, :status_operation, :recovery_timeout ]
18
26
 
19
- def members: () -> [ :name, :arguments, :success_operation, :failure_operation ]
27
+ def members: () -> [ :effect_id, :name, :arguments, :success_operation, :failure_operation, :recovery_operation, :status_operation, :recovery_timeout ]
28
+ end
29
+
30
+ class EffectRecoveryIntent < Data
31
+ attr_reader effect_id(): untyped
32
+
33
+ attr_reader request_id(): untyped
34
+
35
+ def self.new: (untyped effect_id, untyped request_id) -> instance
36
+ | (effect_id: untyped, request_id: untyped) -> instance
37
+
38
+ def self.members: () -> [ :effect_id, :request_id ]
39
+
40
+ def members: () -> [ :effect_id, :request_id ]
20
41
  end
21
42
 
22
43
  class CommitActionIntent < Data
@@ -139,6 +160,8 @@ module SolidObjects
139
160
 
140
161
  @commit_action_intents: Array[CommitActionIntent]
141
162
 
163
+ @effect_recovery_intents: Array[EffectRecoveryIntent]
164
+
142
165
  @effect_intents: Array[EffectIntent]
143
166
 
144
167
  @state: State
@@ -158,8 +181,11 @@ module SolidObjects
158
181
  # @rbs (Symbol | String, String, ?details: Hash[String | Symbol, untyped]) -> bot
159
182
  def reject: (Symbol | String, String, ?details: Hash[String | Symbol, untyped]) -> bot
160
183
 
161
- # @rbs (Symbol | String, ?on_success: Symbol | String?, ?on_failure: Symbol | String?, **untyped) -> nil
162
- def emit: (Symbol | String, ?on_success: Symbol | String?, ?on_failure: Symbol | String?, **untyped) -> nil
184
+ # @rbs (Symbol | String, ?on_success: Symbol | String?, ?on_failure: Symbol | String?, ?on_recovery: Symbol | String?, ?on_status: Symbol | String?, ?recovery_timeout: Numeric?, **untyped) -> effect_handle
185
+ def emit: (Symbol | String, ?on_success: Symbol | String?, ?on_failure: Symbol | String?, ?on_recovery: Symbol | String?, ?on_status: Symbol | String?, ?recovery_timeout: Numeric?, **untyped) -> effect_handle
186
+
187
+ # @rbs (effect_handle) -> nil
188
+ def request_effect_recovery: (effect_handle) -> nil
163
189
 
164
190
  # @rbs () -> OperationDispatcher
165
191
  def transmit: () -> OperationDispatcher
@@ -216,6 +242,9 @@ module SolidObjects
216
242
  # @rbs () -> Array[EffectIntent]
217
243
  def drain_effect_intents: () -> Array[EffectIntent]
218
244
 
245
+ # @rbs () -> Array[EffectRecoveryIntent]
246
+ def drain_effect_recovery_intents: () -> Array[EffectRecoveryIntent]
247
+
219
248
  # @rbs () -> Array[CommitActionIntent]
220
249
  def drain_commit_action_intents: () -> Array[CommitActionIntent]
221
250
 
@@ -232,6 +261,8 @@ module SolidObjects
232
261
 
233
262
  attr_reader effect_intents: untyped
234
263
 
264
+ attr_reader effect_recovery_intents: untyped
265
+
235
266
  attr_reader commit_action_intents: untyped
236
267
 
237
268
  attr_reader reminder_intents: untyped
@@ -246,5 +277,8 @@ module SolidObjects
246
277
 
247
278
  # @rbs (Symbol | String?) -> void
248
279
  def validate_effect_callback!: (Symbol | String?) -> void
280
+
281
+ # @rbs (timeout: Numeric?, operation: String | Symbol?) -> void
282
+ def validate_recovery_timeout!: (timeout: Numeric?, operation: String | Symbol?) -> void
249
283
  end
250
284
  end
@@ -55,6 +55,9 @@ module SolidObjects
55
55
  # @rbs () -> Time
56
56
  def database_now: () -> Time
57
57
 
58
+ # @rbs () -> Time
59
+ def database_clock_now: () -> Time
60
+
58
61
  # @rbs () { () -> untyped } -> untyped
59
62
  def with_lock_retry: () { () -> untyped } -> untyped
60
63
 
@@ -2,6 +2,15 @@
2
2
 
3
3
  module SolidObjects
4
4
  module EffectPayload
5
+ # @rbs [Arguments] (effect_id: String, arguments: Arguments) -> effect_retired_payload[Arguments]
6
+ def self.retired: [Arguments] (effect_id: String, arguments: Arguments) -> effect_retired_payload[Arguments]
7
+
8
+ # @rbs [Arguments, Result] (effect_id: String, arguments: Arguments, result: Result) -> effect_completed_recovery_payload[Arguments, Result]
9
+ def self.recovery_completed: [Arguments, Result] (effect_id: String, arguments: Arguments, result: Result) -> effect_completed_recovery_payload[Arguments, Result]
10
+
11
+ # @rbs (effect_id: String, outcome: effect_observation_outcome) -> effect_observation_payload
12
+ def self.recovery_observation: (effect_id: String, outcome: effect_observation_outcome) -> effect_observation_payload
13
+
5
14
  # @rbs [Arguments, Result] (effect_id: String, arguments: Arguments, result: Result) -> effect_success_payload[Arguments, Result]
6
15
  def self.success: [Arguments, Result] (effect_id: String, arguments: Arguments, result: Result) -> effect_success_payload[Arguments, Result]
7
16
 
@@ -0,0 +1,41 @@
1
+ # Generated from lib/solid_objects/effect_recovery_coordinator.rb with RBS::Inline
2
+
3
+ module SolidObjects
4
+ module EffectRecoveryOutcome
5
+ RETIRED: "retired"
6
+
7
+ DEFERRED: "deferred"
8
+
9
+ PENDING: "pending"
10
+
11
+ COMPLETED: "completed"
12
+
13
+ DEAD: "dead"
14
+
15
+ ALREADY_RETIRED: "already_retired"
16
+
17
+ MISSING: "missing"
18
+ end
19
+
20
+ class EffectRecoveryCoordinator
21
+ # @rbs (instance: Instance, intents: Array[Actor::EffectRecoveryIntent]) -> void
22
+ def check: (instance: Instance, intents: Array[Actor::EffectRecoveryIntent]) -> void
23
+
24
+ # @rbs () -> void
25
+ def recover_available: () -> void
26
+
27
+ private
28
+
29
+ # @rbs () -> ActiveRecord::Relation[EffectRecovery]
30
+ def recovery_candidates: () -> ActiveRecord::Relation[EffectRecovery]
31
+
32
+ # @rbs (instance: Instance, intent: Actor::EffectRecoveryIntent, recovery: EffectRecovery, effect: Effect?, owners: Hash[String, Process], now: Time) -> void
33
+ def check_one: (instance: Instance, intent: Actor::EffectRecoveryIntent, recovery: EffectRecovery, effect: Effect?, owners: Hash[String, Process], now: Time) -> void
34
+
35
+ # @rbs (effect: Effect?, recovery: EffectRecovery, owners: Hash[String, Process], now: Time) -> String
36
+ def observe: (effect: Effect?, recovery: EffectRecovery, owners: Hash[String, Process], now: Time) -> String
37
+
38
+ # @rbs (instance: Instance, effect: Effect, recovery: EffectRecovery, now: Time) -> Message
39
+ def retire: (instance: Instance, effect: Effect, recovery: EffectRecovery, now: Time) -> Message
40
+ end
41
+ end
@@ -0,0 +1,38 @@
1
+ # Generated from lib/solid_objects/process_heartbeat.rb with RBS::Inline
2
+
3
+ module SolidObjects
4
+ class ProcessHeartbeat
5
+ @process_registry: ProcessRegistry
6
+
7
+ @mutex: Thread::Mutex
8
+
9
+ @condition: Thread::ConditionVariable
10
+
11
+ @stopped: bool
12
+
13
+ @thread: Thread?
14
+
15
+ # @rbs (process_registry: ProcessRegistry) -> void
16
+ def initialize: (process_registry: ProcessRegistry) -> void
17
+
18
+ # @rbs () -> void
19
+ def start: () -> void
20
+
21
+ # @rbs () -> void
22
+ def stop: () -> void
23
+
24
+ private
25
+
26
+ attr_reader process_registry: untyped
27
+
28
+ attr_reader mutex: untyped
29
+
30
+ attr_reader condition: untyped
31
+
32
+ # @rbs (Exception) -> void
33
+ def report_failure: (Exception) -> void
34
+
35
+ # @rbs () -> bool
36
+ def wait_for_interval: () -> bool
37
+ end
38
+ end
@@ -0,0 +1,6 @@
1
+ # Generated from app/models/solid_objects/effect_recovery.rb with RBS::Inline
2
+
3
+ module SolidObjects
4
+ class EffectRecovery < Record
5
+ end
6
+ end
@@ -1,4 +1,18 @@
1
1
  module SolidObjects
2
+ type effect_handle = { "effect_id" => String }
3
+
4
+ type effect_retired_payload[Arguments] = {
5
+ "effect_id" => String, "arguments" => Arguments, "outcome" => "retired"
6
+ }
7
+
8
+ type effect_completed_recovery_payload[Arguments, Result] = {
9
+ "effect_id" => String, "arguments" => Arguments, "outcome" => "completed", "result" => Result
10
+ }
11
+
12
+ type effect_observation_outcome = "deferred" | "pending" | "dead" | "already_retired" | "missing"
13
+ type effect_observation_payload = { "effect_id" => String, "outcome" => effect_observation_outcome }
14
+ type effect_recovery_payload[Arguments, Result] = effect_retired_payload[Arguments] | effect_completed_recovery_payload[Arguments, Result] | effect_observation_payload
15
+
2
16
  type effect_error = {
3
17
  "class" => String?,
4
18
  "message" => String,
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solid_objects
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.14.7
4
+ version: 0.15.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lucas Carlson
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-09-15 00:00:00.000000000 Z
11
+ date: 2026-09-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: actioncable
@@ -303,6 +303,7 @@ files:
303
303
  - app/models/solid_objects/claimed_message.rb
304
304
  - app/models/solid_objects/dead_letter.rb
305
305
  - app/models/solid_objects/effect.rb
306
+ - app/models/solid_objects/effect_recovery.rb
306
307
  - app/models/solid_objects/instance.rb
307
308
  - app/models/solid_objects/message.rb
308
309
  - app/models/solid_objects/process.rb
@@ -330,6 +331,7 @@ files:
330
331
  - db/migrate/20260805000000_create_solid_objects_tables.rb
331
332
  - db/migrate/20260806000000_add_state_revision_to_solid_objects_instances.rb
332
333
  - db/migrate/20260813000000_rename_message_dispatch_columns.rb
334
+ - db/migrate/20260915000000_add_solid_objects_effect_recoveries.rb
333
335
  - docs/adr/0001-postgresql-backend.md
334
336
  - docs/adr/0002-jsonb-actor-state.md
335
337
  - docs/adr/0003-mailbox-ordering.md
@@ -350,6 +352,7 @@ files:
350
352
  - docs/dashboard.md
351
353
  - docs/database-schema.md
352
354
  - docs/development.md
355
+ - docs/effect-recovery.md
353
356
  - docs/fit.md
354
357
  - docs/implementation-plan.md
355
358
  - docs/local-testing.md
@@ -418,6 +421,7 @@ files:
418
421
  - lib/solid_objects/dom_identity.rb
419
422
  - lib/solid_objects/effect_executor.rb
420
423
  - lib/solid_objects/effect_payload.rb
424
+ - lib/solid_objects/effect_recovery_coordinator.rb
421
425
  - lib/solid_objects/effect_registry.rb
422
426
  - lib/solid_objects/engine.rb
423
427
  - lib/solid_objects/errors.rb
@@ -433,6 +437,7 @@ files:
433
437
  - lib/solid_objects/operation_dispatcher.rb
434
438
  - lib/solid_objects/payload_broadcast.rb
435
439
  - lib/solid_objects/polling_backoff.rb
440
+ - lib/solid_objects/process_heartbeat.rb
436
441
  - lib/solid_objects/process_pruner.rb
437
442
  - lib/solid_objects/process_registry.rb
438
443
  - lib/solid_objects/reference.rb
@@ -509,6 +514,7 @@ files:
509
514
  - sig/generated/lib/solid_objects/dom_identity.rbs
510
515
  - sig/generated/lib/solid_objects/effect_executor.rbs
511
516
  - sig/generated/lib/solid_objects/effect_payload.rbs
517
+ - sig/generated/lib/solid_objects/effect_recovery_coordinator.rbs
512
518
  - sig/generated/lib/solid_objects/effect_registry.rbs
513
519
  - sig/generated/lib/solid_objects/engine.rbs
514
520
  - sig/generated/lib/solid_objects/errors.rbs
@@ -524,6 +530,7 @@ files:
524
530
  - sig/generated/lib/solid_objects/operation_dispatcher.rbs
525
531
  - sig/generated/lib/solid_objects/payload_broadcast.rbs
526
532
  - sig/generated/lib/solid_objects/polling_backoff.rbs
533
+ - sig/generated/lib/solid_objects/process_heartbeat.rbs
527
534
  - sig/generated/lib/solid_objects/process_pruner.rbs
528
535
  - sig/generated/lib/solid_objects/process_registry.rbs
529
536
  - sig/generated/lib/solid_objects/reference.rbs
@@ -559,6 +566,7 @@ files:
559
566
  - sig/generated/models/solid_objects/claimed_message.rbs
560
567
  - sig/generated/models/solid_objects/dead_letter.rbs
561
568
  - sig/generated/models/solid_objects/effect.rbs
569
+ - sig/generated/models/solid_objects/effect_recovery.rbs
562
570
  - sig/generated/models/solid_objects/instance.rbs
563
571
  - sig/generated/models/solid_objects/message.rbs
564
572
  - sig/generated/models/solid_objects/process.rbs