solid_objects 0.2.0 → 0.3.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 +23 -0
- data/README.md +223 -15
- data/benchmark/adoption_latency.rb +5 -0
- data/benchmark/support.rb +35 -0
- data/docs/architecture.md +62 -15
- data/docs/authorization.md +98 -0
- data/docs/benchmarks.md +75 -1
- data/docs/correctness.md +19 -1
- data/docs/database-schema.md +5 -0
- data/docs/development.md +45 -4
- data/docs/fit.md +98 -0
- data/docs/migrating-existing-state.md +140 -0
- data/docs/operations.md +94 -4
- data/docs/roadmap.md +12 -3
- data/docs/security.md +28 -3
- data/docs/state-migrations.md +6 -0
- data/lib/generators/solid_objects/templates/solid_objects.rb +45 -0
- data/lib/solid_objects/activation.rb +33 -4
- data/lib/solid_objects/actor.rb +47 -6
- data/lib/solid_objects/actor_definition.rb +2 -0
- data/lib/solid_objects/actor_snapshot.rb +10 -4
- data/lib/solid_objects/application_write_guard.rb +24 -0
- data/lib/solid_objects/caller_process.rb +28 -0
- data/lib/solid_objects/cli.rb +44 -5
- data/lib/solid_objects/client.rb +98 -5
- data/lib/solid_objects/commit_action_registry.rb +42 -0
- data/lib/solid_objects/configuration.rb +27 -1
- data/lib/solid_objects/database_adapter.rb +28 -1
- data/lib/solid_objects/database_adapters/mysql.rb +46 -0
- data/lib/solid_objects/database_adapters/postgresql.rb +29 -0
- data/lib/solid_objects/database_adapters/sqlite.rb +28 -0
- data/lib/solid_objects/doctor.rb +311 -0
- data/lib/solid_objects/errors.rb +144 -0
- data/lib/solid_objects/executor.rb +64 -4
- data/lib/solid_objects/instance_pruner.rb +97 -0
- data/lib/solid_objects/message_pruner.rb +97 -0
- data/lib/solid_objects/message_reference.rb +9 -0
- data/lib/solid_objects/process_pruner.rb +49 -0
- data/lib/solid_objects/reference.rb +5 -0
- data/lib/solid_objects/state_snapshot.rb +41 -0
- data/lib/solid_objects/sync_deadline.rb +57 -0
- data/lib/solid_objects/sync_diagnostics.rb +133 -0
- data/lib/solid_objects/synchronous_invocation.rb +26 -7
- data/lib/solid_objects/test_helper.rb +78 -0
- data/lib/solid_objects/version.rb +1 -1
- data/lib/solid_objects/worker.rb +1 -1
- data/lib/solid_objects.rb +34 -0
- data/lib/tasks/solid_objects_tasks.rake +10 -0
- data/sig/generated/lib/solid_objects/activation.rbs +3 -0
- data/sig/generated/lib/solid_objects/actor.rbs +26 -0
- data/sig/generated/lib/solid_objects/application_write_guard.rbs +8 -0
- data/sig/generated/lib/solid_objects/caller_process.rbs +11 -0
- data/sig/generated/lib/solid_objects/cli.rbs +11 -2
- data/sig/generated/lib/solid_objects/client.rbs +15 -0
- data/sig/generated/lib/solid_objects/commit_action_registry.rbs +43 -0
- data/sig/generated/lib/solid_objects/configuration.rbs +27 -7
- data/sig/generated/lib/solid_objects/database_adapter.rbs +9 -0
- data/sig/generated/lib/solid_objects/database_adapters/mysql.rbs +8 -0
- data/sig/generated/lib/solid_objects/database_adapters/postgresql.rbs +8 -0
- data/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs +8 -0
- data/sig/generated/lib/solid_objects/doctor.rbs +111 -0
- data/sig/generated/lib/solid_objects/errors.rbs +118 -0
- data/sig/generated/lib/solid_objects/executor.rbs +12 -0
- data/sig/generated/lib/solid_objects/instance_pruner.rbs +36 -0
- data/sig/generated/lib/solid_objects/message_pruner.rbs +42 -0
- data/sig/generated/lib/solid_objects/message_reference.rbs +3 -0
- data/sig/generated/lib/solid_objects/process_pruner.rbs +27 -0
- data/sig/generated/lib/solid_objects/reference.rbs +3 -0
- data/sig/generated/lib/solid_objects/state_snapshot.rbs +30 -0
- data/sig/generated/lib/solid_objects/sync_deadline.rbs +31 -0
- data/sig/generated/lib/solid_objects/sync_diagnostics.rbs +34 -0
- data/sig/generated/lib/solid_objects/synchronous_invocation.rbs +3 -0
- data/sig/generated/lib/solid_objects/test_helper.rbs +25 -0
- data/sig/generated/lib/solid_objects.rbs +12 -0
- metadata +26 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 48689af6e9b4f08415ddfd549a99d4e7e667e67db5aa4e92bc0052a6d97f51af
|
|
4
|
+
data.tar.gz: 82dc95dc346daf7260356996bfadc99c9c8dd713f2c0fa8c5d8f5efe7859021a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d3a4ce2860cca8656919193b2a9bb6b2c2367552653af106db5fd065bd2bf3c7339a543c5b1bc1fdf1bb7d322e88639a6f20cdba2b5cf551b406e629e6d31598
|
|
7
|
+
data.tar.gz: '086652b090e80b0c6830b269e8857ef6de1944b2c2ab145340eeb4a8ba97a26875b9a612c5105ae46bff7362477fe20c3abde837aa4b87d27f5fb91bf19e316e'
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0 - 2026-08-06
|
|
4
|
+
|
|
5
|
+
- Reject application-record writes from actor handlers and provide registered
|
|
6
|
+
same-database commit actions for fenced atomic changes.
|
|
7
|
+
- Reject synchronous invocation inside an open Solid Objects transaction and
|
|
8
|
+
add adapter database deadlines, durable diagnostics, and recoverable results
|
|
9
|
+
to sync timeouts.
|
|
10
|
+
- Guard handlers, observables, lifecycle hooks, and state migrations from
|
|
11
|
+
direct application-record writes.
|
|
12
|
+
- Add dry-run-first bounded message, process, and opt-in actor-instance
|
|
13
|
+
pruning, configurable retention, and graceful caller-process shutdown.
|
|
14
|
+
- Add authorized committed state snapshots, mutable JSON copies, commit-action
|
|
15
|
+
instrumentation, and deterministic full-runtime Minitest draining.
|
|
16
|
+
|
|
17
|
+
## 0.2.1 - 2026-08-06
|
|
18
|
+
|
|
19
|
+
- Add `solid_objects:doctor` for configuration, schema, policy, runtime, and
|
|
20
|
+
workerless synchronous round-trip verification.
|
|
21
|
+
- Add onboarding guidance for fit decisions, worker requirements,
|
|
22
|
+
authorization, performance and row growth, retention, Sorbet, RuboCop, and
|
|
23
|
+
migrations from existing state stores.
|
|
24
|
+
- Make the early-Action View engine boot regression explicit.
|
|
25
|
+
|
|
3
26
|
## 0.2.0 - 2026-08-06
|
|
4
27
|
|
|
5
28
|
- Make direct actor methods synchronous Durable Object-style RPC.
|
data/README.md
CHANGED
|
@@ -20,8 +20,14 @@ class Counter < SolidObjects::Actor
|
|
|
20
20
|
end
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
-
#
|
|
24
|
-
Counter.ref("global")
|
|
23
|
+
# Synchronous caller-assisted RPC. No worker fleet is required.
|
|
24
|
+
counter = Counter.ref("global")
|
|
25
|
+
count = counter.increment(amount: 5)
|
|
26
|
+
current_count = counter.value
|
|
27
|
+
current_snapshot = counter.snapshot.value
|
|
28
|
+
|
|
29
|
+
# Durable fire-and-forget delivery. A worker processes it later.
|
|
30
|
+
message = counter.async(:increment, amount: 5)
|
|
25
31
|
```
|
|
26
32
|
|
|
27
33
|
`Counter / global` is a logical identity. Like a Durable Object named with
|
|
@@ -30,12 +36,37 @@ locating a Ruby object. Solid Objects activates it when work arrives, commits
|
|
|
30
36
|
its ordered turns one at a time, persists its state, and deactivates it when
|
|
31
37
|
idle. Different identities can run concurrently.
|
|
32
38
|
|
|
39
|
+
The invocation model is the first adoption decision:
|
|
40
|
+
|
|
41
|
+
| Call | Returns | Worker fleet required? |
|
|
42
|
+
| --- | --- | --- |
|
|
43
|
+
| `counter.increment(amount: 5)` | Committed handler result | No |
|
|
44
|
+
| `counter.sync(:increment, amount: 5)` | Committed handler result | No |
|
|
45
|
+
| `counter.value` | Ordered, committed query result | No |
|
|
46
|
+
| `counter.snapshot.value` | Current committed state without a mailbox message | No |
|
|
47
|
+
| `counter.async(:increment, amount: 5)` | `MessageReference` immediately | Yes |
|
|
48
|
+
|
|
49
|
+
Direct methods and `sync` durably enqueue the call, then the Rails caller helps
|
|
50
|
+
execute the actor through the same mailbox, lease, and fencing path as a
|
|
51
|
+
worker. `async` only enqueues; a runtime process handles it later.
|
|
52
|
+
|
|
53
|
+
Synchronous calls fail before enqueue when the Solid Objects database
|
|
54
|
+
connection is already inside a transaction. Actor handlers may read application
|
|
55
|
+
records, but direct Active Record writes are rejected so they cannot escape a
|
|
56
|
+
later actor failure. Use a same-database
|
|
57
|
+
[`commit_action`](#application-database-writes) for atomic database changes and
|
|
58
|
+
[`emit`](#effects) for external I/O.
|
|
59
|
+
|
|
60
|
+
Before adopting a latency-sensitive or high-volume surface, read
|
|
61
|
+
[Is Solid Objects a good fit?](docs/fit.md) and the
|
|
62
|
+
[measured performance and row-growth costs](docs/benchmarks.md).
|
|
63
|
+
|
|
33
64
|
This is a port of the programming model, not Cloudflare's edge runtime or
|
|
34
65
|
platform. Read the conceptual overview at [solidobjects.dev](https://solidobjects.dev/)
|
|
35
66
|
and the exact Rails guarantees in [Correctness and delivery semantics](docs/correctness.md).
|
|
36
67
|
|
|
37
|
-
|
|
38
|
-
but the project does not yet claim production readiness. See
|
|
68
|
+
Solid Objects is an early release. Its correctness core is implemented and
|
|
69
|
+
tested, but the project does not yet claim production readiness. See
|
|
39
70
|
[Status](#status) and the [roadmap](docs/roadmap.md).
|
|
40
71
|
|
|
41
72
|
## Table of contents
|
|
@@ -43,9 +74,11 @@ but the project does not yet claim production readiness. See
|
|
|
43
74
|
- [Cloudflare Durable Objects for Rails](#cloudflare-durable-objects-for-rails)
|
|
44
75
|
- [Reactive ERB](#reactive-erb)
|
|
45
76
|
- [Installation](#installation)
|
|
77
|
+
- [Worker requirements](#worker-requirements)
|
|
46
78
|
- [Defining an actor](#defining-an-actor)
|
|
47
79
|
- [Actor identity](#actor-identity)
|
|
48
80
|
- [Invoking an object](#invoking-an-object)
|
|
81
|
+
- [Application database writes](#application-database-writes)
|
|
49
82
|
- [Effects](#effects)
|
|
50
83
|
- [Reminders](#reminders)
|
|
51
84
|
- [Destroying an object](#destroying-an-object)
|
|
@@ -171,7 +204,7 @@ refresh from current actor state.
|
|
|
171
204
|
`cart.component(:summary)` supports initial rendering of
|
|
172
205
|
`actors/shopping_cart/_summary`. Durable live component replacement and
|
|
173
206
|
Turbo append actions are roadmap work; observable replacement is the live path
|
|
174
|
-
implemented
|
|
207
|
+
implemented today.
|
|
175
208
|
|
|
176
209
|
Reactive views require `turbo-rails` and a working Action Cable adapter in the
|
|
177
210
|
host application. They are optional; the actor runtime itself does not depend
|
|
@@ -187,10 +220,17 @@ Add the gem, install its initializer and migration, then migrate:
|
|
|
187
220
|
bundle add solid_objects
|
|
188
221
|
bin/rails generate solid_objects:install
|
|
189
222
|
bin/rails db:migrate
|
|
223
|
+
bin/rails solid_objects:doctor
|
|
190
224
|
```
|
|
191
225
|
|
|
192
|
-
The
|
|
193
|
-
|
|
226
|
+
The doctor validates configuration and required schema shape, reports
|
|
227
|
+
authorization posture and live runtime roles, and completes a real synchronous
|
|
228
|
+
actor round-trip without a worker. It checks required tables and columns instead
|
|
229
|
+
of a copied migration timestamp, which the host application rewrites. It exits
|
|
230
|
+
unsuccessfully when configuration, schema, or the round-trip is broken.
|
|
231
|
+
|
|
232
|
+
The generated initializer is intentionally inert: all five policies deny by
|
|
233
|
+
default. Replace them with application-specific authorization before sending
|
|
194
234
|
messages, querying state, destroying actors, subscribing to streams, or
|
|
195
235
|
mounting administration routes:
|
|
196
236
|
|
|
@@ -205,16 +245,63 @@ end
|
|
|
205
245
|
```
|
|
206
246
|
|
|
207
247
|
Knowledge of an actor ID or signed stream token is never authorization.
|
|
248
|
+
Read the [policy reference and tenant-aware example](docs/authorization.md)
|
|
249
|
+
before opening a policy. Unconditionally allowing message and query calls is
|
|
250
|
+
reasonable only for a controlled server-side pilot. Keep destroy,
|
|
251
|
+
subscription, and administration denied until each has an authenticated
|
|
252
|
+
caller.
|
|
253
|
+
|
|
254
|
+
The engine uses the application's primary Active Record connection by default.
|
|
255
|
+
See [Database support](#database-support) for a separate database configuration.
|
|
256
|
+
|
|
257
|
+
### Host application tooling
|
|
258
|
+
|
|
259
|
+
Installed engine migrations are copied as
|
|
260
|
+
`db/migrate/*_create_solid_objects_tables.solid_objects.rb`. If the host enables
|
|
261
|
+
`Rails/CreateTableWithTimestamps`, exclude engine-owned migrations rather than
|
|
262
|
+
editing their intentionally specialized hot tables:
|
|
263
|
+
|
|
264
|
+
```yaml
|
|
265
|
+
Rails/CreateTableWithTimestamps:
|
|
266
|
+
Exclude:
|
|
267
|
+
- "db/migrate/*.solid_objects.rb"
|
|
268
|
+
```
|
|
208
269
|
|
|
209
|
-
|
|
210
|
-
|
|
270
|
+
Solid Objects ships inline RBS signatures, not RBI files. Sorbet applications
|
|
271
|
+
can generate the gem RBI with:
|
|
272
|
+
|
|
273
|
+
```bash
|
|
274
|
+
bundle exec tapioca gem solid_objects
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
## Worker requirements
|
|
278
|
+
|
|
279
|
+
Synchronous actors can be adopted without adding a long-running process. Start
|
|
280
|
+
the runtime when the feature introduces asynchronous delivery or outboxes:
|
|
281
|
+
|
|
282
|
+
| Feature | Runtime roles required |
|
|
283
|
+
| --- | --- |
|
|
284
|
+
| Direct actor method or explicit `sync` | None; the caller executes it |
|
|
285
|
+
| Attribute or declared query read | None; the caller executes it |
|
|
286
|
+
| Committed `snapshot` read | None; reads the instance row directly |
|
|
287
|
+
| `destroy` | None |
|
|
288
|
+
| `async` including delayed delivery | Actor worker |
|
|
289
|
+
| One-shot or recurring `schedule` | Reminder scheduler and actor worker |
|
|
290
|
+
| `emit` without an actor callback | Effect worker |
|
|
291
|
+
| `emit` with success or failure callback | Effect worker and actor worker |
|
|
292
|
+
| Actor-to-actor `async` or `send_to` | Effect worker and actor worker |
|
|
293
|
+
| Observable Turbo updates | Broadcast worker, Action Cable, and the actor execution path |
|
|
294
|
+
| Initial `solid_object` server render | No Solid Objects worker; normal Rails rendering |
|
|
295
|
+
|
|
296
|
+
One command starts every Solid Objects role:
|
|
211
297
|
|
|
212
298
|
```bash
|
|
213
299
|
bundle exec solid_objects start
|
|
214
300
|
```
|
|
215
301
|
|
|
216
|
-
|
|
217
|
-
|
|
302
|
+
Deploy and monitor that process before enabling any feature marked as requiring
|
|
303
|
+
a runtime role. A missing worker never makes a durable `async` message
|
|
304
|
+
disappear, but it leaves the message pending indefinitely.
|
|
218
305
|
|
|
219
306
|
## Defining an actor
|
|
220
307
|
|
|
@@ -270,6 +357,20 @@ mailbox. State changes must go through public actor methods or explicit
|
|
|
270
357
|
State, arguments, results, effects, and reminder arguments accept
|
|
271
358
|
JSON-compatible values. Solid Objects never deserializes Ruby `Marshal` data.
|
|
272
359
|
|
|
360
|
+
Attribute readers are ordered mailbox queries and retain message history. For
|
|
361
|
+
a read that does not need mailbox ordering, use an authorized committed
|
|
362
|
+
snapshot:
|
|
363
|
+
|
|
364
|
+
```ruby
|
|
365
|
+
snapshot = cart.snapshot
|
|
366
|
+
items = snapshot.items
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
Snapshots and synchronous results are deeply frozen. Use
|
|
370
|
+
`SolidObjects.mutable_copy(items)` before changing a returned collection.
|
|
371
|
+
Snapshot reads can race with an in-flight turn; they return the most recently
|
|
372
|
+
committed state and do not create or activate a missing actor.
|
|
373
|
+
|
|
273
374
|
Lifecycle hooks are also available:
|
|
274
375
|
|
|
275
376
|
```ruby
|
|
@@ -375,6 +476,37 @@ and MCP request/response boundaries when the handler itself fits the
|
|
|
375
476
|
application's latency budget. If another process owns the activation, the
|
|
376
477
|
caller waits for the durable result using wake-up hints with bounded database
|
|
377
478
|
polling as the fallback. A timeout never cancels the durable invocation.
|
|
479
|
+
`SolidObjects::SyncTimeout` includes actor identity, message ID, sequence,
|
|
480
|
+
durable status, mailbox blocker, and activation-owner diagnostics without
|
|
481
|
+
including message arguments. The configured timeout also bounds adapter
|
|
482
|
+
database lock waits from the enqueue attempt through result observation.
|
|
483
|
+
PostgreSQL uses transaction lock and statement timeouts, SQLite uses its busy
|
|
484
|
+
timeout, and MySQL uses its execution timeout plus InnoDB's one-second minimum
|
|
485
|
+
lock-wait granularity.
|
|
486
|
+
|
|
487
|
+
The durable call can finish after its original caller gives up. Reauthorize and
|
|
488
|
+
recover its eventual result through the durable message identity:
|
|
489
|
+
|
|
490
|
+
```ruby
|
|
491
|
+
begin
|
|
492
|
+
order.submit(timeout: 250.milliseconds)
|
|
493
|
+
rescue SolidObjects::SyncTimeout => error
|
|
494
|
+
result = error.message_reference.wait(
|
|
495
|
+
timeout: 5.seconds,
|
|
496
|
+
authorization_context: Current.user
|
|
497
|
+
)
|
|
498
|
+
end
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
If the enqueue transaction itself cannot finish within the budget, Solid
|
|
502
|
+
Objects raises `SyncEnqueueTimeout`; no durable message exists to recover.
|
|
503
|
+
Timeouts do not preempt Ruby handler code that has already started.
|
|
504
|
+
|
|
505
|
+
Do not wrap a synchronous actor call in `ApplicationRecord.transaction`.
|
|
506
|
+
Solid Objects raises `SolidObjects::SyncInsideTransaction` before enqueue when
|
|
507
|
+
its connection already has an open transaction. Move the actor call before the
|
|
508
|
+
transaction, use `async`, or let the actor own the coordinated change through a
|
|
509
|
+
commit action.
|
|
378
510
|
|
|
379
511
|
Actor code cannot use direct calls or `sync` on another actor; synchronous
|
|
380
512
|
actor-to-actor waits can deadlock in cycles. Use `async` or `send_to` and a
|
|
@@ -396,6 +528,10 @@ The caller receives `SolidObjects::Rejected` with a stable code, message, and
|
|
|
396
528
|
JSON-compatible details. The rejected message remains durable for audit, actor
|
|
397
529
|
state is rolled back, and no later mailbox turn is blocked.
|
|
398
530
|
|
|
531
|
+
`Rejected#code` is a `String`, even when `reject` receives a symbol. Codes must
|
|
532
|
+
match `\A[a-z][a-z0-9_]*\z`; invalid codes raise `ArgumentError` when the
|
|
533
|
+
handler calls `reject`.
|
|
534
|
+
|
|
399
535
|
### Redelivery
|
|
400
536
|
|
|
401
537
|
Sequential does not mean once. A handler can run again after a process crash or
|
|
@@ -412,6 +548,50 @@ end
|
|
|
412
548
|
|
|
413
549
|
External systems must also deduplicate effects using the stable effect ID.
|
|
414
550
|
|
|
551
|
+
## Application database writes
|
|
552
|
+
|
|
553
|
+
Actor handlers execute outside the fenced commit. They may query application
|
|
554
|
+
records, but Solid Objects rejects direct Active Record writes from all
|
|
555
|
+
user-supplied actor code: handlers, observables, activation/deactivation hooks,
|
|
556
|
+
and state migrations. Otherwise an application row could commit before the
|
|
557
|
+
actor later raises or loses its activation fence.
|
|
558
|
+
|
|
559
|
+
For a short database-only change that must commit atomically with actor state,
|
|
560
|
+
stage a named action:
|
|
561
|
+
|
|
562
|
+
```ruby
|
|
563
|
+
class Assessment < SolidObjects::Actor
|
|
564
|
+
attribute :status, default: "open"
|
|
565
|
+
|
|
566
|
+
def finish(attempt_id:, score:)
|
|
567
|
+
self.status = "complete"
|
|
568
|
+
commit_action :complete_attempt, attempt_id:, score:
|
|
569
|
+
end
|
|
570
|
+
end
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
Register its implementation during application boot:
|
|
574
|
+
|
|
575
|
+
```ruby
|
|
576
|
+
SolidObjects.register_commit_action(:complete_attempt) do |arguments, context|
|
|
577
|
+
AssessmentAttempt.find(arguments.fetch("attempt_id")).update!(
|
|
578
|
+
score: arguments.fetch("score"),
|
|
579
|
+
actor_message_id: context.message_id
|
|
580
|
+
)
|
|
581
|
+
end
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
The registered block runs inside the short fenced transaction. Its database
|
|
585
|
+
writes, actor state, message completion, and outboxes all commit or roll back
|
|
586
|
+
together. Commit actions require Solid Objects and `ActiveRecord::Base` to
|
|
587
|
+
share one connection pool. They may be invoked again after a database rollback,
|
|
588
|
+
so keep them deterministic, bounded, and database-only. Never perform network
|
|
589
|
+
I/O, wait for another actor, or enqueue nontransactional work from a commit
|
|
590
|
+
action.
|
|
591
|
+
|
|
592
|
+
When Solid Objects uses a separate actor database, use `emit` and an idempotent
|
|
593
|
+
effect consumer instead; the two databases cannot share one transaction.
|
|
594
|
+
|
|
415
595
|
## Effects
|
|
416
596
|
|
|
417
597
|
Cloudflare Durable Objects can call external services directly. Solid Objects
|
|
@@ -475,6 +655,10 @@ It may read `SolidObjects::Instance.states_for`, `.without_pending_work`, and
|
|
|
475
655
|
`.orphaned`, but every repair must go through `async`. Never bulk-update actor
|
|
476
656
|
state around the lease and fencing checks.
|
|
477
657
|
|
|
658
|
+
Suspended actors should be reported rather than silently resumed. Spread large
|
|
659
|
+
repair batches with `available_at:` so reconciliation cannot stampede one
|
|
660
|
+
mailbox or the worker fleet.
|
|
661
|
+
|
|
478
662
|
## Destroying an object
|
|
479
663
|
|
|
480
664
|
Destroy an actor incarnation through its reference:
|
|
@@ -551,6 +735,11 @@ Important defaults:
|
|
|
551
735
|
| `max_attempts` | 5 |
|
|
552
736
|
| `process_heartbeat_interval` | 15 seconds |
|
|
553
737
|
| `process_alive_threshold` | 60 seconds |
|
|
738
|
+
| `message_retention` | 30 days |
|
|
739
|
+
| `message_retention_by_actor_type` | `{}` |
|
|
740
|
+
| `instance_retention_by_actor_type` | `{}`; instances never expire unless listed |
|
|
741
|
+
| `process_retention` | 7 days |
|
|
742
|
+
| `prune_batch_size` | 1,000 |
|
|
554
743
|
| `worker_count` | 1 |
|
|
555
744
|
| `effect_worker_count` | 1 |
|
|
556
745
|
| `broadcast_worker_count` | 1 |
|
|
@@ -584,10 +773,16 @@ Administration commands require the administration policy:
|
|
|
584
773
|
```bash
|
|
585
774
|
bundle exec solid_objects status
|
|
586
775
|
bundle exec solid_objects cleanup
|
|
776
|
+
bundle exec solid_objects prune_messages
|
|
777
|
+
bundle exec solid_objects prune_instances
|
|
778
|
+
bundle exec solid_objects prune_processes
|
|
587
779
|
bundle exec solid_objects dead_letters
|
|
588
780
|
bundle exec solid_objects retry_dead_letter 123
|
|
589
781
|
```
|
|
590
782
|
|
|
783
|
+
The prune commands preview counts by default. Add `--execute` only after
|
|
784
|
+
reviewing the configured retention policy.
|
|
785
|
+
|
|
591
786
|
The supervisor stops new claims, drains active loops, releases cached leases,
|
|
592
787
|
and marks process rows stopped on graceful shutdown. A hard-killed worker's
|
|
593
788
|
claimed turn is recovered after its process heartbeat or activation lease
|
|
@@ -686,6 +881,13 @@ Do not use it for stateless work, bulk pipelines, CPU-heavy computation,
|
|
|
686
881
|
cross-actor transactions, slow network calls inside handlers, or domains that
|
|
687
882
|
are clearer as normalized Active Record models and direct service objects.
|
|
688
883
|
|
|
884
|
+
High-QPS request reads, rate-limit counters, impression pipelines, large JSON
|
|
885
|
+
documents, and latency budgets that cannot tolerate several coordination
|
|
886
|
+
transactions are explicit anti-patterns. Read the full
|
|
887
|
+
[fit and anti-pattern guide](docs/fit.md) before migrating an existing
|
|
888
|
+
surface, and use the [legacy-state migration cookbook](docs/migrating-existing-state.md)
|
|
889
|
+
for staged cutovers.
|
|
890
|
+
|
|
689
891
|
## Comparisons
|
|
690
892
|
|
|
691
893
|
| Tool | What Solid Objects adds or changes |
|
|
@@ -727,11 +929,13 @@ See the [development guide](docs/development.md) and
|
|
|
727
929
|
|
|
728
930
|
## Status
|
|
729
931
|
|
|
730
|
-
Implemented and tested in 0.
|
|
932
|
+
Implemented and tested in 0.3:
|
|
731
933
|
|
|
732
934
|
- Rails engine, install generator, migrations, and `solid_objects` executable;
|
|
733
935
|
- actor registry, references, JSON state, and state migrations;
|
|
734
936
|
- direct synchronous actor RPC, explicit `sync`, and durable `async`;
|
|
937
|
+
- guarded transaction boundaries, same-database commit actions, adapter lock
|
|
938
|
+
deadlines, structured synchronous timeout diagnostics, and result recovery;
|
|
735
939
|
- durable message history plus ready and claimed membership tables;
|
|
736
940
|
- concurrent sequence allocation and actor creation;
|
|
737
941
|
- activation leases, per-activation tokens, fencing generations, and
|
|
@@ -744,7 +948,11 @@ Implemented and tested in 0.2:
|
|
|
744
948
|
- authorized actor destruction with fenced stale-write rejection and cascading
|
|
745
949
|
durable-work cleanup;
|
|
746
950
|
- durable observable broadcasts and authorized Action Cable refresh;
|
|
747
|
-
- process registration, heartbeats, cleanup, and
|
|
951
|
+
- process registration, heartbeats, caller shutdown, cleanup, and bounded
|
|
952
|
+
message/process retention plus opt-in actor-instance expiration;
|
|
953
|
+
- an opt-in Minitest helper for actor-state isolation and deterministic async
|
|
954
|
+
actor/reminder/effect/broadcast draining;
|
|
955
|
+
- authorized mailbox-free state snapshots and mutable JSON copies; and
|
|
748
956
|
- SQLite, PostgreSQL, and MySQL integration tests.
|
|
749
957
|
|
|
750
958
|
Partially implemented:
|
|
@@ -757,8 +965,8 @@ Partially implemented:
|
|
|
757
965
|
Turbo append actions remain future work;
|
|
758
966
|
- local admission limits exist, but distributed rate limits and global
|
|
759
967
|
admission control do not; and
|
|
760
|
-
- administration views exist, but
|
|
761
|
-
do not.
|
|
968
|
+
- administration views and pruning commands exist, but scheduled maintenance
|
|
969
|
+
and richer audit tools do not.
|
|
762
970
|
|
|
763
971
|
Production readiness requires hardening and operational soak evidence. The
|
|
764
972
|
[roadmap](docs/roadmap.md) tracks that work.
|
data/benchmark/support.rb
CHANGED
|
@@ -145,6 +145,32 @@ module SolidObjectsBenchmark
|
|
|
145
145
|
"p99=#{milliseconds(percentile(sorted, 0.99))}ms"
|
|
146
146
|
end
|
|
147
147
|
|
|
148
|
+
# @rbs () -> void
|
|
149
|
+
def adoption_latency
|
|
150
|
+
instance_count = SolidObjects::Instance.count
|
|
151
|
+
message_count = SolidObjects::Message.count
|
|
152
|
+
|
|
153
|
+
cold_elapsed = Benchmark.realtime do
|
|
154
|
+
CounterActor.ref("adoption-cold").increment
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
reference = CounterActor.ref("adoption-warm")
|
|
158
|
+
reference.increment
|
|
159
|
+
write_samples = Array.new(count) do
|
|
160
|
+
Benchmark.realtime { reference.increment }
|
|
161
|
+
end
|
|
162
|
+
read_samples = Array.new(count) do
|
|
163
|
+
Benchmark.realtime { reference.count }
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
puts "first cold call: #{milliseconds(cold_elapsed)}ms"
|
|
167
|
+
puts latency_summary("warm writes", write_samples)
|
|
168
|
+
puts latency_summary("ordered reads", read_samples)
|
|
169
|
+
puts "durable row growth: " \
|
|
170
|
+
"instances=+#{SolidObjects::Instance.count - instance_count}, " \
|
|
171
|
+
"messages=+#{SolidObjects::Message.count - message_count}"
|
|
172
|
+
end
|
|
173
|
+
|
|
148
174
|
# @rbs () -> void
|
|
149
175
|
def activation_cache
|
|
150
176
|
reference = CounterActor.ref("cache")
|
|
@@ -255,6 +281,15 @@ module SolidObjectsBenchmark
|
|
|
255
281
|
samples.fetch(((samples.length - 1) * fraction).ceil)
|
|
256
282
|
end
|
|
257
283
|
|
|
284
|
+
# @rbs (String, Array[Float]) -> String
|
|
285
|
+
def latency_summary(name, samples)
|
|
286
|
+
sorted = samples.sort
|
|
287
|
+
"#{name} #{samples.length} calls: " \
|
|
288
|
+
"median=#{milliseconds(percentile(sorted, 0.50))}ms " \
|
|
289
|
+
"min=#{milliseconds(sorted.first)}ms " \
|
|
290
|
+
"max=#{milliseconds(sorted.last)}ms"
|
|
291
|
+
end
|
|
292
|
+
|
|
258
293
|
# @rbs (Float) -> String
|
|
259
294
|
def milliseconds(seconds)
|
|
260
295
|
format("%.1f", seconds * 1_000)
|
data/docs/architecture.md
CHANGED
|
@@ -150,6 +150,12 @@ snapshots are deeply frozen. Use `async` for durable fire-and-forget delivery
|
|
|
150
150
|
and `sync` for dynamic operation names. The explicit `message` DSL remains
|
|
151
151
|
available for dynamic definitions.
|
|
152
152
|
|
|
153
|
+
`reference.snapshot` is the explicit unordered read path. It invokes query
|
|
154
|
+
authorization, reads the most recently committed instance state without
|
|
155
|
+
creating a message or activation, applies required state migrations in memory,
|
|
156
|
+
and returns deeply frozen declared attributes. It can race with an in-flight
|
|
157
|
+
turn. `SolidObjects.mutable_copy` creates an independent mutable JSON value.
|
|
158
|
+
|
|
153
159
|
`message` and `query` both execute as durable mailbox turns. A query may not
|
|
154
160
|
mutate state. The executor detects query mutation and fails the message. An
|
|
155
161
|
observable is a named projection of state used by server rendering and realtime
|
|
@@ -161,6 +167,8 @@ Lifecycle hooks are deterministic local hooks:
|
|
|
161
167
|
- `on_deactivate` runs only on graceful local deactivation. Its state changes are not persisted and it must not be used for durable work. Explicit destruction does not run lifecycle hooks.
|
|
162
168
|
|
|
163
169
|
Durable application cleanup belongs in messages, reminders, or effects.
|
|
170
|
+
Handlers, observable blocks, lifecycle hooks, and state migrations all execute
|
|
171
|
+
with application Active Record writes prevented.
|
|
164
172
|
|
|
165
173
|
## Enqueue and sequence allocation
|
|
166
174
|
|
|
@@ -248,6 +256,8 @@ Actor code then executes with no open database transaction and no pinned connect
|
|
|
248
256
|
|
|
249
257
|
- Read and mutate its in-memory state for a message
|
|
250
258
|
- Read state for a query
|
|
259
|
+
- Read application records
|
|
260
|
+
- Stage same-database commit actions
|
|
251
261
|
- Stage effects
|
|
252
262
|
- Stage reminders
|
|
253
263
|
- Stage asynchronous actor messages
|
|
@@ -258,8 +268,16 @@ It cannot:
|
|
|
258
268
|
- Perform a synchronous actor-to-actor wait
|
|
259
269
|
- Assume execution happens once
|
|
260
270
|
- Commit actor state directly
|
|
271
|
+
- Write application records directly
|
|
272
|
+
- Perform external I/O that must be atomic with actor state
|
|
261
273
|
|
|
262
|
-
|
|
274
|
+
Rails write prevention turns a direct Active Record write into
|
|
275
|
+
`ApplicationWriteForbidden` before it reaches the database. Handler and
|
|
276
|
+
observable failures become nonretryable message failures. Activation and
|
|
277
|
+
migration failures happen before the message is claimed. A deactivation-hook
|
|
278
|
+
failure is instrumented and logged, but cannot replace an already committed
|
|
279
|
+
result or prevent best-effort lease release. After actor code, the executor
|
|
280
|
+
validates state and staged data as JSON and computes changed observables.
|
|
263
281
|
|
|
264
282
|
## Fenced commit
|
|
265
283
|
|
|
@@ -268,14 +286,15 @@ Successful completion uses one database transaction:
|
|
|
268
286
|
1. Lock the instance row.
|
|
269
287
|
2. Verify owner, activation token, generation, and an unexpired lease using database time.
|
|
270
288
|
3. Lock the durable message and verify its claimed membership belongs to that owner, activation token, and generation.
|
|
271
|
-
4.
|
|
272
|
-
5.
|
|
273
|
-
6.
|
|
274
|
-
7. Insert
|
|
275
|
-
8. Insert
|
|
276
|
-
9. Insert
|
|
277
|
-
10.
|
|
278
|
-
11.
|
|
289
|
+
4. Execute registered same-database commit actions.
|
|
290
|
+
5. Update native JSON state and state version.
|
|
291
|
+
6. Store the completion timestamp and result on the durable message and delete claimed membership.
|
|
292
|
+
7. Insert staged effects.
|
|
293
|
+
8. Insert or update staged reminders.
|
|
294
|
+
9. Insert staged actor-message outbox rows.
|
|
295
|
+
10. Insert changed-observable broadcast rows.
|
|
296
|
+
11. Update actor last-used time.
|
|
297
|
+
12. Commit.
|
|
279
298
|
|
|
280
299
|
Any lease or message predicate failure raises `LostActivation` and rolls back every item. The stale worker discards its in-memory activation.
|
|
281
300
|
|
|
@@ -346,11 +365,25 @@ overhead at or below 100 milliseconds; polling fallback can pay up to
|
|
|
346
365
|
Caller timeout:
|
|
347
366
|
|
|
348
367
|
- Raises `SolidObjects::SyncTimeout`.
|
|
368
|
+
- Reports durable message, blocker, and activation-owner diagnostics.
|
|
369
|
+
- Exposes a `message_reference` that can reauthorize and wait again.
|
|
349
370
|
- Does not cancel or delete the message.
|
|
350
371
|
- Does not prevent later execution.
|
|
351
372
|
- Leaves the result available until retention cleanup.
|
|
352
373
|
|
|
353
|
-
The
|
|
374
|
+
The configured timeout begins before durable enqueue. PostgreSQL transaction
|
|
375
|
+
lock and statement timeouts, MySQL execution and InnoDB lock-wait timeouts, and
|
|
376
|
+
SQLite busy timeout bound database contention. MySQL rounds lock waits up to
|
|
377
|
+
its one-second minimum. If enqueue cannot commit, `SyncEnqueueTimeout` is
|
|
378
|
+
raised and no durable result exists. Once Ruby handler code starts, it is not
|
|
379
|
+
safely preempted and may outlive the caller budget.
|
|
380
|
+
|
|
381
|
+
The durable row remains after a normal wait timeout and can be recovered with
|
|
382
|
+
`error.message_reference.wait`. Bounded retention commands are implemented;
|
|
383
|
+
public request-ID lookup is not. A sync call made with an already-open
|
|
384
|
+
transaction on the Solid Objects connection raises `SyncInsideTransaction`
|
|
385
|
+
before enqueue. This avoids savepoint enlistment, lock retention until the
|
|
386
|
+
outer commit, and callers timing out on work they indirectly block.
|
|
354
387
|
|
|
355
388
|
`async` performs the same durable enqueue without caller assistance or result
|
|
356
389
|
waiting and immediately returns a `MessageReference`. Runtime workers process
|
|
@@ -505,6 +538,7 @@ end
|
|
|
505
538
|
```
|
|
506
539
|
|
|
507
540
|
Activation applies each step in order. Missing steps, cycles, non-JSON output, or stored versions newer than code fail activation.
|
|
541
|
+
Migration code is subject to the same application-write guard as handlers.
|
|
508
542
|
|
|
509
543
|
Refusing newer stored state is a runtime invariant, not an operator recommendation. The worker does not invoke lifecycle hooks or message code when `stored_state_version > actor_class.state_version`.
|
|
510
544
|
|
|
@@ -573,6 +607,19 @@ An activation becomes idle when it has no due earliest message and no turn in fl
|
|
|
573
607
|
Graceful release conditionally clears owner and expiration only if the generation still matches. Expired leases need no explicit cleanup before another worker claims them.
|
|
574
608
|
|
|
575
609
|
`on_deactivate` is best effort and nondurable. It may not run on crash and cannot be the source of a correctness requirement.
|
|
610
|
+
A failing hook is logged and instrumented as
|
|
611
|
+
`solid_objects.activation.deactivation_failed`; lease release is still
|
|
612
|
+
attempted and a committed synchronous result is preserved.
|
|
613
|
+
|
|
614
|
+
## Retention
|
|
615
|
+
|
|
616
|
+
Terminal messages and stopped process rows have configurable bounded pruning.
|
|
617
|
+
Actor instances never expire by default. Types listed in
|
|
618
|
+
`instance_retention_by_actor_type` become eligible after their idle cutoff only
|
|
619
|
+
when they are unowned, unpaused, and have no ready/claimed work, scheduled
|
|
620
|
+
reminder, unfinished or dead outbox, or dead letter. The pruner locks and
|
|
621
|
+
rechecks each candidate before cascading deletion. All pruning commands
|
|
622
|
+
preview by default and require administration authorization.
|
|
576
623
|
|
|
577
624
|
## Advisory locks
|
|
578
625
|
|
|
@@ -612,7 +659,7 @@ The semantic guarantees are common, but their coordination implementations diffe
|
|
|
612
659
|
| JSON state | JSONB | JSON | Rails JSON type |
|
|
613
660
|
| Executable-work indexes | Ready/claimed membership tables | Ready/claimed membership tables | Ready/claimed membership tables |
|
|
614
661
|
| Write isolation | Row locks and MVCC | InnoDB row/next-key locks and MVCC | One writer, serializable writes |
|
|
615
|
-
|
|
|
662
|
+
| Sync contention deadline | Transaction lock and statement timeouts | Execution timeout and one-second-granularity InnoDB lock timeout | Busy timeout |
|
|
616
663
|
| Lease clock | Database current time | Database current time | Database current time |
|
|
617
664
|
|
|
618
665
|
All backends use unique identity and sequence constraints, short transactions, and conditional owner/generation/expiry fencing. Passing one backend's suite is not evidence for another.
|
|
@@ -633,14 +680,14 @@ All backends use unique identity and sequence constraints, short transactions, a
|
|
|
633
680
|
12. **How are leases renewed?** Conditional database update by instance, owner, generation, and unexpired lease.
|
|
634
681
|
13. **How does graceful shutdown work?** Stop claims, finish current turn within timeout, release cached leases, stop heartbeat, mark process stopped.
|
|
635
682
|
14. **How does synchronous invocation work across processes?** The caller first tries to claim and execute the actor locally. If another process owns it, a wake-up adapter prompts a durable result query and bounded polling remains the fallback.
|
|
636
|
-
15. **What happens after caller timeout?**
|
|
637
|
-
16. **How are results cleaned up?**
|
|
683
|
+
15. **What happens after caller timeout?** A committed message continues and its eventual result can be recovered with the timeout's authorized message reference. An enqueue timeout leaves no message. Running Ruby code is not preempted.
|
|
684
|
+
16. **How are results cleaned up?** `prune_messages` deletes eligible terminal history in bounded batches after global or per-actor retention. It previews by default and preserves live work, dead letters, retry links, and unfinished outboxes.
|
|
638
685
|
17. **How are large mailboxes managed?** The implemented controls are the per-actor mailbox cap, payload caps, and fair activation yields; rate and global admission controls remain roadmap work.
|
|
639
|
-
18. **How are completed messages pruned?**
|
|
686
|
+
18. **How are completed messages pruned?** Operators schedule the dry-run-reviewed `prune_messages --execute` command. Solid Objects does not run deletion automatically.
|
|
640
687
|
19. **How are state migrations performed?** Explicit one-step actor migrations on activation, persisted only with a successful fenced commit.
|
|
641
688
|
20. **What happens during rolling deploys?** Newer state can make old workers incompatible; deploys must preserve backward readability or drain old workers.
|
|
642
689
|
21. **How are subscriptions authorized?** Verify signed identity, resolve registered type, invoke host authorization, then stream.
|
|
643
690
|
22. **How are lost broadcasts recovered?** Current-state refresh after reconnect; durable outbox retries server delivery.
|
|
644
691
|
23. **How are actor-to-actor cycles handled?** Synchronous actor waits are rejected; asynchronous request/result messages avoid call-stack cycles.
|
|
645
|
-
24. **Which operations are transactional?** The transaction map above lists every atomic boundary
|
|
692
|
+
24. **Which operations are transactional?** The transaction map above lists every atomic boundary. Actor code and external I/O are outside; registered same-pool commit actions execute inside the fenced state/message transaction.
|
|
646
693
|
25. **Which guarantees depend on PostgreSQL?** None of the public semantics are PostgreSQL-only. PostgreSQL and MySQL depend on row-lock claiming; SQLite depends on serialized write transactions. Each backend's guarantee depends on its adapter-specific integration tests.
|