solid_objects 0.14.0 → 0.14.2
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 +75 -0
- data/README.md +349 -1124
- data/Rakefile +5 -0
- data/docs/architecture.md +40 -1
- data/docs/correctness.md +9 -0
- data/docs/operations.md +169 -21
- data/docs/reminders.md +112 -0
- data/docs/roadmap.md +7 -1
- data/examples/at_least_once/actor.rb +14 -0
- data/examples/at_least_once/boot.rb +47 -0
- data/examples/at_least_once/demo.rb +92 -0
- data/examples/at_least_once/effect_worker.rb +40 -0
- data/examples/at_least_once/sink.rb +27 -0
- data/lib/solid_objects/actor_channel.rb +33 -3
- data/lib/solid_objects/engine.rb +4 -0
- data/lib/solid_objects/version.rb +1 -1
- data/sig/generated/lib/solid_objects/actor_channel.rbs +10 -0
- metadata +8 -2
data/Rakefile
CHANGED
|
@@ -32,6 +32,11 @@ task :steep do
|
|
|
32
32
|
sh "bundle exec steep check"
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
+
desc "Prove the at-least-once clause by crashing an effect worker at a sink"
|
|
36
|
+
task :at_least_once do
|
|
37
|
+
sh "bundle exec ruby examples/at_least_once/demo.rb"
|
|
38
|
+
end
|
|
39
|
+
|
|
35
40
|
desc "Scan the Rails engine for security warnings"
|
|
36
41
|
task :security do
|
|
37
42
|
sh "bundle exec brakeman --force --no-pager -q ."
|
data/docs/architecture.md
CHANGED
|
@@ -435,6 +435,45 @@ send_to(InventoryActor.ref("sku-123")).reserve(order_id: id, quantity: 2)
|
|
|
435
435
|
|
|
436
436
|
The source actor commit never waits for the target. The outbox worker allocates the target's sequence after source commit. There is no global order across actors.
|
|
437
437
|
|
|
438
|
+
## Registering effect and commit-action handlers
|
|
439
|
+
|
|
440
|
+
Register an effect handler during application boot. The stable effect ID is the
|
|
441
|
+
idempotency key for the provider call:
|
|
442
|
+
|
|
443
|
+
```ruby
|
|
444
|
+
SolidObjects.register_effect(:charge_payment) do |arguments, context|
|
|
445
|
+
Payments.charge(
|
|
446
|
+
idempotency_key: context.id,
|
|
447
|
+
payment_id: arguments.fetch("payment_id"),
|
|
448
|
+
amount_cents: arguments.fetch("amount_cents")
|
|
449
|
+
)
|
|
450
|
+
end
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
A success callback receives `effect_id:`, the originally staged `arguments:`,
|
|
454
|
+
and `result:`. A failure callback receives `effect_id:`, `arguments:`, and
|
|
455
|
+
`error:`, so an actor can correlate concurrent effects without storing a
|
|
456
|
+
separate callback ledger.
|
|
457
|
+
|
|
458
|
+
A commit action is registered the same way and runs inside the short fenced
|
|
459
|
+
transaction:
|
|
460
|
+
|
|
461
|
+
```ruby
|
|
462
|
+
SolidObjects.register_commit_action(:complete_attempt) do |arguments, context|
|
|
463
|
+
AssessmentAttempt.find(arguments.fetch("attempt_id")).update!(
|
|
464
|
+
score: arguments.fetch("score"),
|
|
465
|
+
actor_message_id: context.message_id
|
|
466
|
+
)
|
|
467
|
+
end
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
Commit actions require Solid Objects and `ActiveRecord::Base` to share one
|
|
471
|
+
connection pool, and may be invoked again after a database rollback, so keep
|
|
472
|
+
them deterministic, bounded, and database-only. When Solid Objects uses a
|
|
473
|
+
separate actor database, use `emit` and an idempotent effect consumer instead;
|
|
474
|
+
two databases cannot share one transaction.
|
|
475
|
+
|
|
476
|
+
|
|
438
477
|
## Reminders
|
|
439
478
|
|
|
440
479
|
A reminder record contains actor identity, a reminder name, target message, JSON arguments, next run time, optional interval, status, and occurrence counter.
|
|
@@ -443,7 +482,7 @@ A reminder record contains actor identity, a reminder name, target message, JSON
|
|
|
443
482
|
schedule(at: 30.minutes.from_now).expire
|
|
444
483
|
```
|
|
445
484
|
|
|
446
|
-
Reminders are keyed by `(actor, reminder name)`, enforced by a unique index on `(instance_id, name)`. `schedule` is therefore an upsert: scheduling a name that is already armed moves that alarm instead of adding another, which is what makes re-arming safe from a handler that may run more than once. An actor needing several pending items should arm one alarm for the earliest and drain everything due when it fires, rather than one alarm per item; the [reminders guide](
|
|
485
|
+
Reminders are keyed by `(actor, reminder name)`, enforced by a unique index on `(instance_id, name)`. `schedule` is therefore an upsert: scheduling a name that is already armed moves that alarm instead of adding another, which is what makes re-arming safe from a handler that may run more than once. An actor needing several pending items should arm one alarm for the earliest and drain everything due when it fires, rather than one alarm per item; the [reminders guide](reminders.md#one-alarm-for-a-whole-queue) shows that pattern. A move that changes `next_run_at` emits `solid_objects.reminder.replaced`, because the replacement is otherwise indistinguishable from a first schedule.
|
|
447
486
|
|
|
448
487
|
When due, the scheduler locks the source instance and creates a normal mailbox
|
|
449
488
|
row with an idempotency key derived from reminder ID and occurrence. The
|
data/docs/correctness.md
CHANGED
|
@@ -93,6 +93,15 @@ end
|
|
|
93
93
|
The guard prevents a repeated state transition. The effect consumer still
|
|
94
94
|
deduplicates with `context.id`.
|
|
95
95
|
|
|
96
|
+
This clause is observable, not decorative. `bundle exec rake at_least_once`
|
|
97
|
+
crashes an effect worker between the external sink write and the
|
|
98
|
+
acknowledgement, restarts one after the liveness threshold, and shows the sink
|
|
99
|
+
reading 2 with deduplication off. Both deliveries carry the same `context.id`
|
|
100
|
+
at attempts 1 and 2. A guard on that id absorbs the same duplicate and the
|
|
101
|
+
sink reads 1. The actor state commits exactly once in both runs. The source is
|
|
102
|
+
`examples/at_least_once/`; solid-objects-js runs the same proof with
|
|
103
|
+
`pnpm run test:at-least-once`.
|
|
104
|
+
|
|
96
105
|
## Atomic boundaries
|
|
97
106
|
|
|
98
107
|
The following are atomic:
|
data/docs/operations.md
CHANGED
|
@@ -23,6 +23,103 @@ process, its activations, and its claimed messages untouched, including when an
|
|
|
23
23
|
application call overlaps the probe. A database busy enough to block cleanup
|
|
24
24
|
reports a failed or warned check rather than raising out of the command.
|
|
25
25
|
|
|
26
|
+
## Installing and upgrading
|
|
27
|
+
|
|
28
|
+
Review [CHANGELOG.md](CHANGELOG.md) for compatibility and deployment-order
|
|
29
|
+
notes, then update the gem:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
bundle update solid_objects
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
If the `Gemfile` pins an exact version, update that constraint first and run
|
|
36
|
+
`bundle install`. Commit both `Gemfile.lock` and the copied Solid Objects
|
|
37
|
+
migrations.
|
|
38
|
+
|
|
39
|
+
Copy only migrations that the newer gem has added, migrate, and verify the
|
|
40
|
+
installation:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
bin/rails solid_objects:install:migrations
|
|
44
|
+
bin/rails db:migrate
|
|
45
|
+
bin/rails solid_objects:doctor
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The migration task skips engine migrations already present in the application
|
|
49
|
+
and gives new migrations host-specific timestamps. Inspect the resulting
|
|
50
|
+
`db/migrate/*.solid_objects.rb` files before applying them. Do not rerun
|
|
51
|
+
`generate solid_objects:install` during an upgrade because that also attempts
|
|
52
|
+
to regenerate the application initializer.
|
|
53
|
+
|
|
54
|
+
When Solid Objects uses a separate database configuration named `actors`, copy
|
|
55
|
+
and run migrations through that database's configured migration path:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
DATABASE=actors bin/rails solid_objects:install:migrations
|
|
59
|
+
bin/rails db:migrate:actors
|
|
60
|
+
bin/rails solid_objects:doctor
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
For production, back up the actor database and run new migrations before
|
|
64
|
+
starting application or Solid Objects worker processes that require the new
|
|
65
|
+
schema. Restart the web and Solid Objects worker fleet after the bundle and
|
|
66
|
+
schema are current. For releases that change actor state versions, also follow
|
|
67
|
+
the [state migration and rolling-deployment guide](docs/state-migrations.md);
|
|
68
|
+
Rails schema migrations and actor state migrations are separate concerns.
|
|
69
|
+
|
|
70
|
+
### Host application tooling
|
|
71
|
+
|
|
72
|
+
Installed engine migrations are copied as
|
|
73
|
+
`db/migrate/*_create_solid_objects_tables.solid_objects.rb`. If the host enables
|
|
74
|
+
`Rails/CreateTableWithTimestamps`, exclude engine-owned migrations rather than
|
|
75
|
+
editing their intentionally specialized hot tables:
|
|
76
|
+
|
|
77
|
+
```yaml
|
|
78
|
+
Rails/CreateTableWithTimestamps:
|
|
79
|
+
Exclude:
|
|
80
|
+
- "db/migrate/*.solid_objects.rb"
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Solid Objects ships inline RBS signatures, not RBI files. Sorbet applications
|
|
84
|
+
can generate the gem RBI with:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
bundle exec tapioca gem solid_objects
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Running an extension in the same process
|
|
91
|
+
|
|
92
|
+
An extension gem can register its own long-running component, and
|
|
93
|
+
`solid_objects start` runs it beside the built-in roles. The component joins the
|
|
94
|
+
same supervision, the same replacement after a crash, and the same shutdown
|
|
95
|
+
timeout, so an operator deploys and monitors one process instead of two:
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
SolidObjects.configure do |configuration|
|
|
99
|
+
configuration.register_component { MyExtension::FlushEngine.new }
|
|
100
|
+
end
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Pass `count:` for more than one instance. The block runs once for each instance,
|
|
104
|
+
and again when the supervisor replaces a crashed one, so no two components share
|
|
105
|
+
an object.
|
|
106
|
+
|
|
107
|
+
A registered component answers four methods, the contract the built-in roles
|
|
108
|
+
already keep:
|
|
109
|
+
|
|
110
|
+
| Method | Purpose |
|
|
111
|
+
| --- | --- |
|
|
112
|
+
| `run` | Runs the loop. The supervisor calls it in its own thread |
|
|
113
|
+
| `request_shutdown` | Asks the loop to finish. It must make `run` return |
|
|
114
|
+
| `stopped?` | Reports whether the component already finished |
|
|
115
|
+
| `stop` | Forces cleanup when the shutdown timeout expires first |
|
|
116
|
+
|
|
117
|
+
The supervisor checks that contract when it builds the component, and a missing
|
|
118
|
+
method raises `ArgumentError` as the supervisor starts, rather than hanging a
|
|
119
|
+
shutdown later. Registration itself never calls the block, so a component is
|
|
120
|
+
free to need a database connection that the application does not have while it
|
|
121
|
+
boots.
|
|
122
|
+
|
|
26
123
|
## Runtime
|
|
27
124
|
|
|
28
125
|
Start all configured roles:
|
|
@@ -31,12 +128,35 @@ Start all configured roles:
|
|
|
31
128
|
bundle exec solid_objects start
|
|
32
129
|
```
|
|
33
130
|
|
|
34
|
-
The
|
|
131
|
+
The generator and the migrations prepare the database and start nothing. A
|
|
132
|
+
process claims ready messages only after this command starts its roles, so an
|
|
133
|
+
application that serves web requests alone leaves every `async` message ready.
|
|
134
|
+
The message is durable and waits for the first process that runs the roles. A
|
|
135
|
+
direct call or an explicit `sync` needs no running role, because the caller's
|
|
136
|
+
own path executes it.
|
|
137
|
+
|
|
138
|
+
The engine loads the host application's `app/actors` directories in every
|
|
139
|
+
process that boots the application, and the command repeats that load before
|
|
35
140
|
starting any runtime role, even when Rails eager loading is disabled. Actors in
|
|
36
141
|
the conventional directory do not need initializer references. The targeted
|
|
37
142
|
loader participates in Rails preparation callbacks so a development reload can
|
|
38
143
|
replace a registered actor class without loading unrelated application code.
|
|
39
144
|
|
|
145
|
+
An actor registers itself as its class loads, and a web process resolves
|
|
146
|
+
actors by name for Cable subscriptions and component renders. Loading them in
|
|
147
|
+
every process is what lets a freshly booted web process serve a live card for
|
|
148
|
+
an actor no request in that process has rendered yet.
|
|
149
|
+
|
|
150
|
+
Worker and outbox counts can be overridden on the command line:
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
bundle exec solid_objects start \
|
|
154
|
+
--workers 4 \
|
|
155
|
+
--effect-workers 2 \
|
|
156
|
+
--broadcast-workers 2 \
|
|
157
|
+
--reminder-schedulers 1
|
|
158
|
+
```
|
|
159
|
+
|
|
40
160
|
Inspect process records and clean stale ownership:
|
|
41
161
|
|
|
42
162
|
```bash
|
|
@@ -57,26 +177,45 @@ bundle exec solid_objects retry_dead_letter 123
|
|
|
57
177
|
|
|
58
178
|
## Configuration
|
|
59
179
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
180
|
+
Configure Solid Objects in `config/initializers/solid_objects.rb`:
|
|
181
|
+
|
|
182
|
+
```ruby
|
|
183
|
+
SolidObjects.configure do |configuration|
|
|
184
|
+
configuration.worker_count = 4
|
|
185
|
+
configuration.lease_duration = 30.seconds
|
|
186
|
+
configuration.lease_renewal_interval = 10.seconds
|
|
187
|
+
configuration.max_messages_per_activation_pass = 50
|
|
188
|
+
configuration.max_activation_duration = 5.seconds
|
|
189
|
+
end
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
| Setting | Default |
|
|
193
|
+
| --- | ---: |
|
|
194
|
+
| `polling_interval` | 0.1 seconds |
|
|
195
|
+
| `idle_polling_interval` | 1 second |
|
|
196
|
+
| `sync_polling_interval` | 0.05 seconds |
|
|
197
|
+
| `lease_duration` | 30 seconds |
|
|
198
|
+
| `lease_renewal_interval` | 10 seconds |
|
|
199
|
+
| `idle_deactivation_timeout` | 30 seconds |
|
|
200
|
+
| `max_messages_per_activation_pass` | 50 |
|
|
201
|
+
| `max_activation_duration` | 5 seconds |
|
|
202
|
+
| `max_mailbox_length` | 10,000 |
|
|
203
|
+
| `max_attempts` | 5 |
|
|
204
|
+
| `process_heartbeat_interval` | 15 seconds |
|
|
205
|
+
| `process_alive_threshold` | 60 seconds |
|
|
206
|
+
| `message_retention` | 30 days |
|
|
207
|
+
| `message_retention_by_actor_type` | `{}` |
|
|
208
|
+
| `instance_retention_by_actor_type` | `{}`; instances never expire unless listed |
|
|
209
|
+
| `process_retention` | 7 days |
|
|
210
|
+
| `prune_batch_size` | 1,000 |
|
|
211
|
+
| `worker_count` | 1 |
|
|
212
|
+
| `effect_worker_count` | 1 |
|
|
213
|
+
| `broadcast_worker_count` | 1 |
|
|
214
|
+
| `reminder_scheduler_count` | 1 |
|
|
215
|
+
|
|
216
|
+
Payload, state, and result byte limits; retry delay; table prefix; logging;
|
|
217
|
+
wake-up; broadcast; database; and authorization adapters are also configurable.
|
|
218
|
+
Invalid lease intervals, component counts, and size limits fail fast at boot.
|
|
80
219
|
|
|
81
220
|
Keep lease duration comfortably above renewal interval and expected database
|
|
82
221
|
pause time. A handler can exceed the pass-duration budget because Ruby code is
|
|
@@ -187,6 +326,15 @@ transaction rejection, commit-action start/completion/failure, effect and
|
|
|
187
326
|
broadcast enqueue/completion, reminder enqueue, actor destruction/expiration,
|
|
188
327
|
retention pruning, process cleanup, and supervisor lifecycle.
|
|
189
328
|
|
|
329
|
+
`solid_objects.subscription.rejected` reports a rejected Cable subscription.
|
|
330
|
+
A rejection closes the socket and leaves the page holding a stale card, and
|
|
331
|
+
the browser cannot say which of the conditions applied. The event carries the
|
|
332
|
+
`reason`, the actor identity, and the `error_class` where an exception caused
|
|
333
|
+
it. The reason is one of `unregistered_actor_type`, `invalid_stream_token`,
|
|
334
|
+
`invalid_component_token`, `malformed_component_registration`,
|
|
335
|
+
`missing_subscription_parameter`, or `unauthorized`. Exception messages are
|
|
336
|
+
excluded, because a component or payload failure can carry actor state.
|
|
337
|
+
|
|
190
338
|
`solid_objects.reminder.replaced` reports a `schedule` call that moved an alarm
|
|
191
339
|
already armed under the same name on the same actor, carrying the actor
|
|
192
340
|
identity, reminder `name`, `previous_run_at`, and `next_run_at`. Reminders are
|
data/docs/reminders.md
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# Reminders
|
|
2
|
+
|
|
3
|
+
Reminders are Solid Objects' durable equivalent of the Durable Objects Alarms
|
|
4
|
+
API. One-shot and recurring alarms are actor-owned database records:
|
|
5
|
+
|
|
6
|
+
```ruby
|
|
7
|
+
def schedule_evaluation
|
|
8
|
+
schedule(
|
|
9
|
+
at: 1.hour.from_now,
|
|
10
|
+
every: 1.hour,
|
|
11
|
+
missed: :latest
|
|
12
|
+
).evaluate(account_id:)
|
|
13
|
+
end
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Use `missed: :latest` to coalesce missed occurrences or `missed: :all` to
|
|
17
|
+
enqueue each one.
|
|
18
|
+
|
|
19
|
+
## A reminder is one named alarm per actor
|
|
20
|
+
|
|
21
|
+
The uniqueness key is `(actor, reminder name)`. Scheduling a name that is
|
|
22
|
+
already armed **moves the existing alarm** rather than adding a second one. The
|
|
23
|
+
database enforces this with a unique index on `(instance_id, name)`.
|
|
24
|
+
|
|
25
|
+
This is the same model as Orleans reminders and Durable Objects alarms, and it
|
|
26
|
+
is what makes a reminder safe to re-arm from a handler that may run more than
|
|
27
|
+
once. Without a key the name is the operation, so this is a data-loss bug:
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
# Wrong. Every entry overwrites the previous entry's alarm.
|
|
31
|
+
def add(entry:)
|
|
32
|
+
self.entries = entries + [ entry ]
|
|
33
|
+
schedule(at: entry.fetch("wait_until")).deliver
|
|
34
|
+
end
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Two entries leave one reminder. The earlier wake-up never happens, nothing
|
|
38
|
+
raises, and nothing is logged except a `solid_objects.reminder.replaced` event.
|
|
39
|
+
|
|
40
|
+
## An alarm per item, with `key:`
|
|
41
|
+
|
|
42
|
+
Pass `key:` when an actor is waiting on several things at once. The key is your
|
|
43
|
+
own identifier for the item, and it names that item's alarm, so each item gets
|
|
44
|
+
one:
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
def add(entry:)
|
|
48
|
+
self.entries = entries + [ entry ]
|
|
49
|
+
schedule(at: entry.fetch("wait_until"), key: entry.fetch("id")).deliver
|
|
50
|
+
end
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Two entries now leave two reminders. Scheduling the same key again moves that
|
|
54
|
+
item's alarm and leaves the others alone, which is what makes a keyed reminder
|
|
55
|
+
as safe to re-arm as an unkeyed one. The operation still decides which handler
|
|
56
|
+
runs; the key only decides which alarm is which.
|
|
57
|
+
|
|
58
|
+
A key must be non-empty, and the name it becomes must fit the 191-character
|
|
59
|
+
column, which is checked on the composed name rather than the key alone so a
|
|
60
|
+
long operation and a short key are caught too.
|
|
61
|
+
|
|
62
|
+
The key is separated from the operation by a colon, so an operation may not hold
|
|
63
|
+
one. Otherwise an unkeyed `deliver:item` and a `deliver` keyed `item` would be
|
|
64
|
+
one name, and the second would silently take the first one's alarm. A key may
|
|
65
|
+
hold colons of its own, because the operation before the first one cannot.
|
|
66
|
+
|
|
67
|
+
## One alarm for a whole queue
|
|
68
|
+
|
|
69
|
+
A key per item is not always what you want. An actor that only ever needs to
|
|
70
|
+
know "what is next" can keep one alarm and let the handler drain everything now
|
|
71
|
+
due before arming the next:
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
def add(entry:)
|
|
75
|
+
self.entries = (entries + [ entry ]).sort_by { |item| item.fetch("wait_until") }
|
|
76
|
+
arm_next
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def deliver
|
|
80
|
+
now = Time.current.to_i
|
|
81
|
+
due, pending = entries.partition { |item| item.fetch("wait_until") <= now }
|
|
82
|
+
due.each { |item| emit :send_push, **item.symbolize_keys }
|
|
83
|
+
self.entries = pending
|
|
84
|
+
arm_next
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def arm_next
|
|
90
|
+
earliest = entries.first
|
|
91
|
+
return unless earliest
|
|
92
|
+
|
|
93
|
+
schedule(at: Time.at(earliest.fetch("wait_until"))).deliver
|
|
94
|
+
end
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
That costs one reminder row instead of one per item, and a coalesced occurrence
|
|
98
|
+
cannot strand an entry because the handler drains by time rather than by alarm.
|
|
99
|
+
Prefer it when the queue is large and the items are interchangeable; prefer
|
|
100
|
+
`key:` when an item needs its own alarm that can be moved on its own.
|
|
101
|
+
|
|
102
|
+
Solid Objects has no `unschedule`. A reminder stops when its handler does not
|
|
103
|
+
re-arm it, and destroying an actor removes its reminders.
|
|
104
|
+
|
|
105
|
+
Self-scheduling actors should also have a low-frequency application reconciler.
|
|
106
|
+
It may read `SolidObjects::Instance.states_for`, `.without_pending_work`, and
|
|
107
|
+
`.orphaned`, but every repair must go through `async`. Never bulk-update actor
|
|
108
|
+
state around the lease and fencing checks.
|
|
109
|
+
|
|
110
|
+
Suspended actors should be reported rather than silently resumed. Spread large
|
|
111
|
+
repair batches with `available_at:` so reconciliation cannot stampede one
|
|
112
|
+
mailbox or the worker fleet.
|
data/docs/roadmap.md
CHANGED
|
@@ -126,7 +126,13 @@
|
|
|
126
126
|
untested end to end, which is how a raising payload block came to reject the
|
|
127
127
|
subscription; it is now covered and confined, and the payload authorization
|
|
128
128
|
context is resolved through `payload_authorization_context` rather than
|
|
129
|
-
handing the block a raw Cable connection.
|
|
129
|
+
handing the block a raw Cable connection. Actor registration in a web process
|
|
130
|
+
was assumed rather than arranged: an actor registered only as a side effect
|
|
131
|
+
of its class loading, and only the worker CLI loaded the host's `app/actors`,
|
|
132
|
+
so a lazily loading web process rejected subscriptions for actors it could
|
|
133
|
+
serve until some earlier request happened to load the class. The engine now
|
|
134
|
+
loads them in every process, and a rejected subscription reports which
|
|
135
|
+
condition caused it instead of closing the socket silently.
|
|
130
136
|
- Backpressure: mailbox/payload/state/result caps and fair yields exist;
|
|
131
137
|
distributed per-actor rate limits and global admission control do not.
|
|
132
138
|
- Administration: `SolidObjects::Web` is a mountable Rack dashboard covering
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "active_record"
|
|
5
|
+
require "solid_objects"
|
|
6
|
+
|
|
7
|
+
# Boots a standalone runtime against a shared SQLite file, the way the
|
|
8
|
+
# demo's parent and its crashing children all attach to one database.
|
|
9
|
+
module AtLeastOnceBoot
|
|
10
|
+
ROOT = File.expand_path("../..", __dir__)
|
|
11
|
+
|
|
12
|
+
# @rbs (String database_path) -> void
|
|
13
|
+
def self.call(database_path)
|
|
14
|
+
ActiveRecord::Base.establish_connection(
|
|
15
|
+
adapter: "sqlite3",
|
|
16
|
+
database: database_path,
|
|
17
|
+
pool: 5,
|
|
18
|
+
timeout: 5_000
|
|
19
|
+
)
|
|
20
|
+
ActiveRecord::Migration.verbose = false
|
|
21
|
+
migrate unless ActiveRecord::Base.connection.table_exists?("solid_objects_instances")
|
|
22
|
+
|
|
23
|
+
require "solid_objects/database_adapter"
|
|
24
|
+
%w[
|
|
25
|
+
record process instance message ready_message claimed_message
|
|
26
|
+
reminder effect broadcast dead_letter
|
|
27
|
+
].each { |model| require File.join(ROOT, "app/models/solid_objects", model) }
|
|
28
|
+
|
|
29
|
+
SolidObjects.configuration.authorize_message = ->(**) { true }
|
|
30
|
+
SolidObjects.configuration.authorize_query = ->(**) { true }
|
|
31
|
+
SolidObjects.configuration.polling_interval = 0.01
|
|
32
|
+
SolidObjects.configuration.process_heartbeat_interval = 0.075
|
|
33
|
+
SolidObjects.configuration.process_alive_threshold = 0.3
|
|
34
|
+
SolidObjects.configuration.lease_duration = 0.25
|
|
35
|
+
SolidObjects.configuration.lease_renewal_interval = 0.05
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @rbs () -> void
|
|
39
|
+
def self.migrate
|
|
40
|
+
require File.join(ROOT, "db/migrate/20260805000000_create_solid_objects_tables")
|
|
41
|
+
require File.join(ROOT, "db/migrate/20260806000000_add_state_revision_to_solid_objects_instances")
|
|
42
|
+
require File.join(ROOT, "db/migrate/20260813000000_rename_message_dispatch_columns")
|
|
43
|
+
CreateSolidObjectsTables.new.migrate(:up)
|
|
44
|
+
AddStateRevisionToSolidObjectsInstances.new.migrate(:up)
|
|
45
|
+
RenameMessageDispatchColumns.new.migrate(:up)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
# An executable proof for the at-least-once clause: a contract clause
|
|
4
|
+
# nobody can observe firing is decoration. Run with:
|
|
5
|
+
#
|
|
6
|
+
# bundle exec rake at_least_once
|
|
7
|
+
#
|
|
8
|
+
# Phase one crashes an effect worker between the external sink write and
|
|
9
|
+
# the acknowledgement, restarts one, and shows the sink reading 2 with
|
|
10
|
+
# deduplication off. Both deliveries carry the same stable effect id.
|
|
11
|
+
# Phase two repeats the crash with a guard on that id; the sink reads 1.
|
|
12
|
+
# The actor state commits exactly once in both phases.
|
|
13
|
+
|
|
14
|
+
require_relative "boot"
|
|
15
|
+
require_relative "actor"
|
|
16
|
+
require_relative "sink"
|
|
17
|
+
require "fileutils"
|
|
18
|
+
require "json"
|
|
19
|
+
require "rbconfig"
|
|
20
|
+
require "tmpdir"
|
|
21
|
+
|
|
22
|
+
directory = Dir.mktmpdir("solid_objects_at_least_once_")
|
|
23
|
+
database_path = File.join(directory, "state.sqlite3")
|
|
24
|
+
AtLeastOnceBoot.call(database_path)
|
|
25
|
+
|
|
26
|
+
# @rbs (String message) -> void
|
|
27
|
+
def prove(message)
|
|
28
|
+
raise "proof failed: #{message}" unless yield
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @rbs (String actor_id) -> void
|
|
32
|
+
def stage_one_delivery(actor_id)
|
|
33
|
+
DeliveryCounter.ref(actor_id).async.deliver
|
|
34
|
+
worker = SolidObjects::Worker.new
|
|
35
|
+
begin
|
|
36
|
+
worker.run_until_idle
|
|
37
|
+
ensure
|
|
38
|
+
worker.stop
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @rbs (database_path: String, sink_path: String, mode: String, deduplication: String) -> Integer?
|
|
43
|
+
def run_effect_worker(database_path:, sink_path:, mode:, deduplication:)
|
|
44
|
+
script = File.expand_path("effect_worker.rb", __dir__)
|
|
45
|
+
pid = Process.spawn(
|
|
46
|
+
RbConfig.ruby, script, database_path, sink_path, mode, deduplication,
|
|
47
|
+
chdir: AtLeastOnceBoot::ROOT
|
|
48
|
+
)
|
|
49
|
+
_pid, status = Process.wait2(pid)
|
|
50
|
+
status.exitstatus
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @rbs (database_path: String, sink_path: String, deduplication: String) -> void
|
|
54
|
+
def crash_then_recover(database_path:, sink_path:, deduplication:)
|
|
55
|
+
crash = run_effect_worker(database_path:, sink_path:, mode: "crash", deduplication:)
|
|
56
|
+
prove("the first delivery crashed before acknowledgement") { crash == 1 }
|
|
57
|
+
sleep 0.4
|
|
58
|
+
recovery = run_effect_worker(database_path:, sink_path:, mode: "complete", deduplication:)
|
|
59
|
+
prove("the second delivery completed and acknowledged") { recovery == 0 }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
begin
|
|
63
|
+
sink_off = File.join(directory, "sink-dedup-off.json")
|
|
64
|
+
stage_one_delivery("dedup-off")
|
|
65
|
+
crash_then_recover(database_path:, sink_path: sink_off, deduplication: "off")
|
|
66
|
+
deliveries = AtLeastOnceSink.read(sink_off)
|
|
67
|
+
effect_ids = deliveries.map { |delivery| delivery.fetch("effect_id") }
|
|
68
|
+
state_off = SolidObjects::Instance.find_by!(actor_id: "dedup-off").state.fetch("count")
|
|
69
|
+
prove("the state commit happened exactly once") { state_off == 1 }
|
|
70
|
+
prove("the sink observed the duplicate") { deliveries.length == 2 }
|
|
71
|
+
prove("both deliveries carried the same stable effect id") { effect_ids.uniq.length == 1 }
|
|
72
|
+
|
|
73
|
+
sink_on = File.join(directory, "sink-dedup-on.json")
|
|
74
|
+
stage_one_delivery("dedup-on")
|
|
75
|
+
crash_then_recover(database_path:, sink_path: sink_on, deduplication: "on")
|
|
76
|
+
guarded = AtLeastOnceSink.read(sink_on)
|
|
77
|
+
state_on = SolidObjects::Instance.find_by!(actor_id: "dedup-on").state.fetch("count")
|
|
78
|
+
prove("the state commit happened exactly once") { state_on == 1 }
|
|
79
|
+
prove("the stable effect id absorbed the duplicate") { guarded.length == 1 }
|
|
80
|
+
|
|
81
|
+
puts JSON.pretty_generate(
|
|
82
|
+
duplicate: {
|
|
83
|
+
state_commits: state_off,
|
|
84
|
+
sink_deliveries: deliveries.length,
|
|
85
|
+
same_effect_id: effect_ids.uniq.length == 1,
|
|
86
|
+
attempts: deliveries.map { |delivery| delivery.fetch("attempt") }
|
|
87
|
+
},
|
|
88
|
+
remedy: { state_commits: state_on, sink_deliveries: guarded.length }
|
|
89
|
+
)
|
|
90
|
+
ensure
|
|
91
|
+
FileUtils.remove_entry(directory) if directory
|
|
92
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
require_relative "boot"
|
|
4
|
+
require_relative "actor"
|
|
5
|
+
require_relative "sink"
|
|
6
|
+
|
|
7
|
+
database_path, sink_path, mode, deduplication = ARGV
|
|
8
|
+
raise ArgumentError, "usage: effect_worker.rb DATABASE SINK crash|complete on|off" unless deduplication
|
|
9
|
+
|
|
10
|
+
AtLeastOnceBoot.call(database_path.to_s)
|
|
11
|
+
|
|
12
|
+
SolidObjects.register_effect(:record) do |_arguments, context|
|
|
13
|
+
AtLeastOnceSink.record(
|
|
14
|
+
path: sink_path.to_s,
|
|
15
|
+
effect_id: context.id,
|
|
16
|
+
attempt: context.attempt,
|
|
17
|
+
deduplication: deduplication.to_sym
|
|
18
|
+
)
|
|
19
|
+
# A crash between the external write and the acknowledgement: the sink
|
|
20
|
+
# has the delivery, the effect row never completes.
|
|
21
|
+
Process.exit!(1) if mode == "crash"
|
|
22
|
+
nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Production runs this on the dead-process-cleanup interval; the demo runs
|
|
26
|
+
# it once, after the liveness threshold, to release the crashed claim.
|
|
27
|
+
SolidObjects::ProcessRegistry.cleanup_dead
|
|
28
|
+
|
|
29
|
+
effect_executor = SolidObjects::EffectExecutor.new
|
|
30
|
+
begin
|
|
31
|
+
worked = false
|
|
32
|
+
200.times do
|
|
33
|
+
worked = effect_executor.run_once
|
|
34
|
+
break if worked
|
|
35
|
+
sleep 0.01
|
|
36
|
+
end
|
|
37
|
+
raise "no effect became claimable" unless worked
|
|
38
|
+
ensure
|
|
39
|
+
effect_executor.stop
|
|
40
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
# The external system in the at-least-once demo: a JSON file that records
|
|
6
|
+
# every delivery it accepts. With deduplication :off it accepts everything,
|
|
7
|
+
# which makes an at-least-once duplicate visible. With deduplication :on it
|
|
8
|
+
# accepts each stable effect id once, which is the documented remedy.
|
|
9
|
+
module AtLeastOnceSink
|
|
10
|
+
# @rbs (String path) -> Array[Hash[String, untyped]]
|
|
11
|
+
def self.read(path)
|
|
12
|
+
JSON.parse(File.read(path))
|
|
13
|
+
rescue Errno::ENOENT
|
|
14
|
+
[]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @rbs (path: String, effect_id: String, attempt: Integer, deduplication: Symbol) -> bool
|
|
18
|
+
def self.record(path:, effect_id:, attempt:, deduplication:)
|
|
19
|
+
deliveries = read(path)
|
|
20
|
+
seen = deliveries.any? { |delivery| delivery.fetch("effect_id") == effect_id }
|
|
21
|
+
return false if deduplication == :on && seen
|
|
22
|
+
|
|
23
|
+
deliveries << { "effect_id" => effect_id, "attempt" => attempt }
|
|
24
|
+
File.write(path, JSON.pretty_generate(deliveries))
|
|
25
|
+
true
|
|
26
|
+
end
|
|
27
|
+
end
|