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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +24 -0
- data/README.md +1 -0
- data/Rakefile +1 -1
- data/app/models/solid_objects/effect_recovery.rb +10 -0
- data/benchmark/support.rb +3 -0
- data/db/migrate/20260915000000_add_solid_objects_effect_recoveries.rb +17 -0
- data/docs/architecture.md +48 -0
- data/docs/development.md +110 -0
- data/docs/effect-recovery.md +203 -0
- data/docs/operations.md +6 -0
- data/docs/reminders.md +8 -0
- data/docs/roadmap.md +11 -0
- data/examples/at_least_once/boot.rb +3 -1
- data/lib/solid_objects/actor.rb +43 -4
- data/lib/solid_objects/actor_signatures.rb +80 -0
- data/lib/solid_objects/database_adapter.rb +13 -0
- data/lib/solid_objects/doctor.rb +1 -0
- data/lib/solid_objects/effect_executor.rb +18 -15
- data/lib/solid_objects/effect_payload.rb +42 -0
- data/lib/solid_objects/effect_recovery_coordinator.rb +143 -0
- data/lib/solid_objects/executor.rb +14 -2
- data/lib/solid_objects/process_heartbeat.rb +64 -0
- data/lib/solid_objects/process_pruner.rb +1 -1
- data/lib/solid_objects/process_registry.rb +9 -6
- data/lib/solid_objects/test_helper.rb +1 -0
- data/lib/solid_objects/version.rb +1 -1
- data/lib/solid_objects.rb +3 -0
- data/sig/generated/lib/solid_objects/actor.rbs +40 -6
- data/sig/generated/lib/solid_objects/actor_signatures.rbs +24 -0
- data/sig/generated/lib/solid_objects/database_adapter.rbs +3 -0
- data/sig/generated/lib/solid_objects/effect_payload.rbs +23 -0
- data/sig/generated/lib/solid_objects/effect_recovery_coordinator.rbs +41 -0
- data/sig/generated/lib/solid_objects/process_heartbeat.rbs +38 -0
- data/sig/generated/models/solid_objects/effect_recovery.rbs +6 -0
- data/sig/public/effect_payload.rbs +33 -0
- metadata +15 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 30a65b9701ec2d566a8d62c2ee428ec61cbfb5bc24d1390905a46943594b78cf
|
|
4
|
+
data.tar.gz: 39daa8fe6b2586902eee4e9bd6fbdeef4e2666651e707d647a91e5644243bb79
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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"
|
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
|