solid_objects 0.14.7 → 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 +15 -0
- data/README.md +1 -0
- 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/effect-recovery.md +203 -0
- data/docs/roadmap.md +5 -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 +1 -1
- data/lib/solid_objects/database_adapter.rb +13 -0
- data/lib/solid_objects/doctor.rb +1 -0
- data/lib/solid_objects/effect_executor.rb +7 -0
- data/lib/solid_objects/effect_payload.rb +15 -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 +2 -0
- data/sig/generated/lib/solid_objects/actor.rbs +40 -6
- data/sig/generated/lib/solid_objects/database_adapter.rbs +3 -0
- data/sig/generated/lib/solid_objects/effect_payload.rbs +9 -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 +14 -0
- metadata +9 -1
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,20 @@
|
|
|
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
|
+
|
|
3
18
|
## 0.14.7 - 2026-09-14
|
|
4
19
|
|
|
5
20
|
- 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.
|
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
|
data/lib/solid_objects/actor.rb
CHANGED
|
@@ -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) ->
|
|
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) ->
|
|
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
|
data/lib/solid_objects/doctor.rb
CHANGED
|
@@ -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:
|
|
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
|
|
|
@@ -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
|
|
@@ -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)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
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
|
-
|
|
15
|
-
|
|
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) ->
|
|
162
|
-
def emit: (Symbol | String, ?on_success: Symbol | String?, ?on_failure: Symbol | String?, **untyped) ->
|
|
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
|
|
@@ -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
|
|
@@ -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,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: solid_objects
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.15.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Lucas Carlson
|
|
@@ -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
|