solid_objects 0.2.1 → 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 +14 -0
- data/README.md +129 -8
- data/docs/architecture.md +62 -15
- data/docs/authorization.md +16 -3
- data/docs/benchmarks.md +6 -2
- data/docs/correctness.md +19 -1
- data/docs/database-schema.md +5 -0
- data/docs/development.md +40 -0
- data/docs/fit.md +10 -2
- data/docs/migrating-existing-state.md +8 -1
- data/docs/operations.md +74 -36
- data/docs/roadmap.md +8 -2
- data/docs/security.md +25 -3
- data/docs/state-migrations.md +2 -0
- data/lib/generators/solid_objects/templates/solid_objects.rb +30 -2
- 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/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/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/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 +19 -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,19 @@
|
|
|
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
|
+
|
|
3
17
|
## 0.2.1 - 2026-08-06
|
|
4
18
|
|
|
5
19
|
- Add `solid_objects:doctor` for configuration, schema, policy, runtime, and
|
data/README.md
CHANGED
|
@@ -24,6 +24,7 @@ end
|
|
|
24
24
|
counter = Counter.ref("global")
|
|
25
25
|
count = counter.increment(amount: 5)
|
|
26
26
|
current_count = counter.value
|
|
27
|
+
current_snapshot = counter.snapshot.value
|
|
27
28
|
|
|
28
29
|
# Durable fire-and-forget delivery. A worker processes it later.
|
|
29
30
|
message = counter.async(:increment, amount: 5)
|
|
@@ -41,13 +42,21 @@ The invocation model is the first adoption decision:
|
|
|
41
42
|
| --- | --- | --- |
|
|
42
43
|
| `counter.increment(amount: 5)` | Committed handler result | No |
|
|
43
44
|
| `counter.sync(:increment, amount: 5)` | Committed handler result | No |
|
|
44
|
-
| `counter.value` |
|
|
45
|
+
| `counter.value` | Ordered, committed query result | No |
|
|
46
|
+
| `counter.snapshot.value` | Current committed state without a mailbox message | No |
|
|
45
47
|
| `counter.async(:increment, amount: 5)` | `MessageReference` immediately | Yes |
|
|
46
48
|
|
|
47
49
|
Direct methods and `sync` durably enqueue the call, then the Rails caller helps
|
|
48
50
|
execute the actor through the same mailbox, lease, and fencing path as a
|
|
49
51
|
worker. `async` only enqueues; a runtime process handles it later.
|
|
50
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
|
+
|
|
51
60
|
Before adopting a latency-sensitive or high-volume surface, read
|
|
52
61
|
[Is Solid Objects a good fit?](docs/fit.md) and the
|
|
53
62
|
[measured performance and row-growth costs](docs/benchmarks.md).
|
|
@@ -56,8 +65,8 @@ This is a port of the programming model, not Cloudflare's edge runtime or
|
|
|
56
65
|
platform. Read the conceptual overview at [solidobjects.dev](https://solidobjects.dev/)
|
|
57
66
|
and the exact Rails guarantees in [Correctness and delivery semantics](docs/correctness.md).
|
|
58
67
|
|
|
59
|
-
|
|
60
|
-
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
|
|
61
70
|
[Status](#status) and the [roadmap](docs/roadmap.md).
|
|
62
71
|
|
|
63
72
|
## Table of contents
|
|
@@ -69,6 +78,7 @@ but the project does not yet claim production readiness. See
|
|
|
69
78
|
- [Defining an actor](#defining-an-actor)
|
|
70
79
|
- [Actor identity](#actor-identity)
|
|
71
80
|
- [Invoking an object](#invoking-an-object)
|
|
81
|
+
- [Application database writes](#application-database-writes)
|
|
72
82
|
- [Effects](#effects)
|
|
73
83
|
- [Reminders](#reminders)
|
|
74
84
|
- [Destroying an object](#destroying-an-object)
|
|
@@ -194,7 +204,7 @@ refresh from current actor state.
|
|
|
194
204
|
`cart.component(:summary)` supports initial rendering of
|
|
195
205
|
`actors/shopping_cart/_summary`. Durable live component replacement and
|
|
196
206
|
Turbo append actions are roadmap work; observable replacement is the live path
|
|
197
|
-
implemented
|
|
207
|
+
implemented today.
|
|
198
208
|
|
|
199
209
|
Reactive views require `turbo-rails` and a working Action Cable adapter in the
|
|
200
210
|
host application. They are optional; the actor runtime itself does not depend
|
|
@@ -273,6 +283,7 @@ the runtime when the feature introduces asynchronous delivery or outboxes:
|
|
|
273
283
|
| --- | --- |
|
|
274
284
|
| Direct actor method or explicit `sync` | None; the caller executes it |
|
|
275
285
|
| Attribute or declared query read | None; the caller executes it |
|
|
286
|
+
| Committed `snapshot` read | None; reads the instance row directly |
|
|
276
287
|
| `destroy` | None |
|
|
277
288
|
| `async` including delayed delivery | Actor worker |
|
|
278
289
|
| One-shot or recurring `schedule` | Reminder scheduler and actor worker |
|
|
@@ -346,6 +357,20 @@ mailbox. State changes must go through public actor methods or explicit
|
|
|
346
357
|
State, arguments, results, effects, and reminder arguments accept
|
|
347
358
|
JSON-compatible values. Solid Objects never deserializes Ruby `Marshal` data.
|
|
348
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
|
+
|
|
349
374
|
Lifecycle hooks are also available:
|
|
350
375
|
|
|
351
376
|
```ruby
|
|
@@ -451,6 +476,37 @@ and MCP request/response boundaries when the handler itself fits the
|
|
|
451
476
|
application's latency budget. If another process owns the activation, the
|
|
452
477
|
caller waits for the durable result using wake-up hints with bounded database
|
|
453
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.
|
|
454
510
|
|
|
455
511
|
Actor code cannot use direct calls or `sync` on another actor; synchronous
|
|
456
512
|
actor-to-actor waits can deadlock in cycles. Use `async` or `send_to` and a
|
|
@@ -492,6 +548,50 @@ end
|
|
|
492
548
|
|
|
493
549
|
External systems must also deduplicate effects using the stable effect ID.
|
|
494
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
|
+
|
|
495
595
|
## Effects
|
|
496
596
|
|
|
497
597
|
Cloudflare Durable Objects can call external services directly. Solid Objects
|
|
@@ -555,6 +655,10 @@ It may read `SolidObjects::Instance.states_for`, `.without_pending_work`, and
|
|
|
555
655
|
`.orphaned`, but every repair must go through `async`. Never bulk-update actor
|
|
556
656
|
state around the lease and fencing checks.
|
|
557
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
|
+
|
|
558
662
|
## Destroying an object
|
|
559
663
|
|
|
560
664
|
Destroy an actor incarnation through its reference:
|
|
@@ -631,6 +735,11 @@ Important defaults:
|
|
|
631
735
|
| `max_attempts` | 5 |
|
|
632
736
|
| `process_heartbeat_interval` | 15 seconds |
|
|
633
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 |
|
|
634
743
|
| `worker_count` | 1 |
|
|
635
744
|
| `effect_worker_count` | 1 |
|
|
636
745
|
| `broadcast_worker_count` | 1 |
|
|
@@ -664,10 +773,16 @@ Administration commands require the administration policy:
|
|
|
664
773
|
```bash
|
|
665
774
|
bundle exec solid_objects status
|
|
666
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
|
|
667
779
|
bundle exec solid_objects dead_letters
|
|
668
780
|
bundle exec solid_objects retry_dead_letter 123
|
|
669
781
|
```
|
|
670
782
|
|
|
783
|
+
The prune commands preview counts by default. Add `--execute` only after
|
|
784
|
+
reviewing the configured retention policy.
|
|
785
|
+
|
|
671
786
|
The supervisor stops new claims, drains active loops, releases cached leases,
|
|
672
787
|
and marks process rows stopped on graceful shutdown. A hard-killed worker's
|
|
673
788
|
claimed turn is recovered after its process heartbeat or activation lease
|
|
@@ -814,11 +929,13 @@ See the [development guide](docs/development.md) and
|
|
|
814
929
|
|
|
815
930
|
## Status
|
|
816
931
|
|
|
817
|
-
Implemented and tested in 0.
|
|
932
|
+
Implemented and tested in 0.3:
|
|
818
933
|
|
|
819
934
|
- Rails engine, install generator, migrations, and `solid_objects` executable;
|
|
820
935
|
- actor registry, references, JSON state, and state migrations;
|
|
821
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;
|
|
822
939
|
- durable message history plus ready and claimed membership tables;
|
|
823
940
|
- concurrent sequence allocation and actor creation;
|
|
824
941
|
- activation leases, per-activation tokens, fencing generations, and
|
|
@@ -831,7 +948,11 @@ Implemented and tested in 0.2:
|
|
|
831
948
|
- authorized actor destruction with fenced stale-write rejection and cascading
|
|
832
949
|
durable-work cleanup;
|
|
833
950
|
- durable observable broadcasts and authorized Action Cable refresh;
|
|
834
|
-
- 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
|
|
835
956
|
- SQLite, PostgreSQL, and MySQL integration tests.
|
|
836
957
|
|
|
837
958
|
Partially implemented:
|
|
@@ -844,8 +965,8 @@ Partially implemented:
|
|
|
844
965
|
Turbo append actions remain future work;
|
|
845
966
|
- local admission limits exist, but distributed rate limits and global
|
|
846
967
|
admission control do not; and
|
|
847
|
-
- administration views exist, but
|
|
848
|
-
do not.
|
|
968
|
+
- administration views and pruning commands exist, but scheduled maintenance
|
|
969
|
+
and richer audit tools do not.
|
|
849
970
|
|
|
850
971
|
Production readiness requires hardening and operational soak evidence. The
|
|
851
972
|
[roadmap](docs/roadmap.md) tracks that work.
|
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.
|
data/docs/authorization.md
CHANGED
|
@@ -10,12 +10,14 @@ intentionally inert until the host application defines its trust boundary.
|
|
|
10
10
|
| Policy | Gates | Caller context | Risk if opened globally |
|
|
11
11
|
| --- | --- | --- | --- |
|
|
12
12
|
| `authorize_message` | Direct actor methods, explicit `sync` messages, and public `async` enqueue | Value passed as `authorization_context:`; often a user, service principal, or trusted internal marker | Anyone reaching the call site can mutate any known actor identity |
|
|
13
|
-
| `authorize_query` | Attribute reads, declared queries, observable reads, and component reads | Explicit call context or the Rails view context supplied by `solid_object` | Actor state can leak across users or tenants |
|
|
13
|
+
| `authorize_query` | Attribute reads, declared queries, committed snapshots, observable reads, and component reads | Explicit call context or the Rails view context supplied by `solid_object` | Actor state can leak across users or tenants |
|
|
14
14
|
| `authorize_destroy` | `reference.destroy` | Value passed as `authorization_context:` | Complete actor state, mailbox, reminders, and pending outboxes can be deleted |
|
|
15
15
|
| `authorize_subscription` | Action Cable subscription to one actor stream | The `ActionCable::Connection` object | Clients can receive future observable updates for other actors |
|
|
16
|
-
| `authorize_administration` | Engine administration controllers, process inspection/cleanup, and dead-letter inspection/retry | Rails controller or `{ source: "cli" }` | Operational metadata, arguments, errors, and retries become exposed or mutable |
|
|
16
|
+
| `authorize_administration` | Engine administration controllers, process inspection/cleanup/pruning, message pruning, and dead-letter inspection/retry | Rails controller or `{ source: "cli" }` | Operational metadata, arguments, errors, deletion, and retries become exposed or mutable |
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
Waiting again through `MessageReference#wait` reauthorizes the stored
|
|
19
|
+
invocation as a message or query. Internal reminder, effect-callback, and
|
|
20
|
+
actor-to-actor deliveries come from
|
|
19
21
|
already committed runtime rows and do not re-enter the public client policy.
|
|
20
22
|
|
|
21
23
|
## A tenant-aware policy
|
|
@@ -80,6 +82,17 @@ Keep `authorize_destroy`, `authorize_subscription`, and
|
|
|
80
82
|
Replace unconditional policies before exposing actor IDs to controllers, API
|
|
81
83
|
clients, MCP tools, jobs carrying user input, or browser subscriptions.
|
|
82
84
|
|
|
85
|
+
For commands executed only on hosts where shell access is already the
|
|
86
|
+
authenticated administration boundary, the generated initializer shows an
|
|
87
|
+
optional CLI-scoped policy:
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
configuration.authorize_administration = lambda do |authorization_context:, **|
|
|
91
|
+
authorization_context.is_a?(Hash) &&
|
|
92
|
+
authorization_context[:source] == "cli"
|
|
93
|
+
end
|
|
94
|
+
```
|
|
95
|
+
|
|
83
96
|
Run `bin/rails solid_objects:doctor` after configuration. Its neutral policy
|
|
84
97
|
probe is deliberately conservative: a context-aware policy may correctly warn
|
|
85
98
|
because it denies a `nil` context.
|
data/docs/benchmarks.md
CHANGED
|
@@ -66,8 +66,8 @@ Attribute reads are therefore not free snapshots from the instance row. They
|
|
|
66
66
|
are ordered durable query messages and grow message history exactly like
|
|
67
67
|
writes.
|
|
68
68
|
|
|
69
|
-
|
|
70
|
-
growth as:
|
|
69
|
+
Built-in pruning previews by default and must be scheduled explicitly. Budget
|
|
70
|
+
message growth before retention as:
|
|
71
71
|
|
|
72
72
|
```text
|
|
73
73
|
daily durable messages = daily actor writes + daily actor reads + daily callbacks
|
|
@@ -76,6 +76,10 @@ daily durable messages = daily actor writes + daily actor reads + daily callback
|
|
|
76
76
|
Review the [retention requirements](operations.md#retention-and-backups) before
|
|
77
77
|
adopting a high-volume surface.
|
|
78
78
|
|
|
79
|
+
Use `reference.snapshot` for an authorized current-state read when mailbox
|
|
80
|
+
ordering is unnecessary. It avoids a message row but can observe state before
|
|
81
|
+
an in-flight turn commits.
|
|
82
|
+
|
|
79
83
|
## Measure the host application
|
|
80
84
|
|
|
81
85
|
Run the adoption benchmark against a dedicated empty database with the same
|
data/docs/correctness.md
CHANGED
|
@@ -101,13 +101,20 @@ The following are atomic:
|
|
|
101
101
|
- activation owner, expiration, and generation acquisition;
|
|
102
102
|
- ready-to-claimed membership move and attempt increment;
|
|
103
103
|
- state, state version, message result/completion, claimed deletion, effects,
|
|
104
|
-
reminders, outbound actor messages, and
|
|
104
|
+
same-database commit actions, reminders, outbound actor messages, and
|
|
105
|
+
observable broadcasts;
|
|
105
106
|
- failed-attempt record plus ready reinsertion or dead letter;
|
|
106
107
|
- effect completion plus its optional actor outcome message;
|
|
107
108
|
- reminder occurrence enqueue plus reminder advancement; and
|
|
108
109
|
- actor destruction plus cascading removal of all actor-owned rows.
|
|
109
110
|
|
|
110
111
|
Actor Ruby code and external I/O are never inside the actor-state transaction.
|
|
112
|
+
Rails write prevention rejects direct Active Record writes while handlers,
|
|
113
|
+
observables, lifecycle hooks, or state migrations run. A registered commit
|
|
114
|
+
action is the only application-record write path inside the fenced commit, and
|
|
115
|
+
it is available only when Solid Objects and `ActiveRecord::Base` share one
|
|
116
|
+
connection pool. Commit actions must contain only bounded database work.
|
|
117
|
+
External I/O belongs in the effect outbox.
|
|
111
118
|
|
|
112
119
|
## Synchronous invocation
|
|
113
120
|
|
|
@@ -119,6 +126,17 @@ result. A worker may win the activation instead; the caller then observes the
|
|
|
119
126
|
durable result through wake-up hints with bounded polling as fallback.
|
|
120
127
|
|
|
121
128
|
Timeout raises `SolidObjects::SyncTimeout` but does not cancel the message.
|
|
129
|
+
The exception reports actor identity, message ID and sequence, durable status,
|
|
130
|
+
an earlier mailbox blocker, and activation-owner metadata without exposing
|
|
131
|
+
arguments. Its `message_reference` can reauthorize and wait for the eventual
|
|
132
|
+
result. Adapter lock/query deadlines cover the durable enqueue and coordination
|
|
133
|
+
transactions. If enqueue cannot commit, `SyncEnqueueTimeout` is raised and no
|
|
134
|
+
message reference exists. MySQL lock waits have one-second InnoDB granularity.
|
|
135
|
+
Ruby handlers that already started are not preempted.
|
|
136
|
+
|
|
137
|
+
A synchronous call made while the Solid Objects connection already has an open
|
|
138
|
+
transaction raises `SolidObjects::SyncInsideTransaction` before the message is
|
|
139
|
+
enqueued.
|
|
122
140
|
Destroying the actor while a synchronous caller waits removes its message,
|
|
123
141
|
wakes the caller, and raises `SolidObjects::ActorDestroyed`.
|
|
124
142
|
|
data/docs/database-schema.md
CHANGED
|
@@ -24,6 +24,11 @@ Indexes:
|
|
|
24
24
|
- owner: dead-process cleanup;
|
|
25
25
|
- last used/ID: retention and reconciliation.
|
|
26
26
|
|
|
27
|
+
Instance expiration is disabled unless its actor type has an explicit
|
|
28
|
+
retention policy. The pruner uses the last-used index for candidate discovery,
|
|
29
|
+
then locks and rechecks that the actor has no owner, mailbox membership,
|
|
30
|
+
scheduled reminder, unresolved outbox, or dead letter before deletion.
|
|
31
|
+
|
|
27
32
|
### `messages`
|
|
28
33
|
|
|
29
34
|
Durable immutable invocation identity and arguments plus sequence, attempt
|
data/docs/development.md
CHANGED
|
@@ -28,6 +28,46 @@ SOLID_OBJECTS_DATABASE_URL=mysql2://... bundle exec rake test
|
|
|
28
28
|
Each database run must start from an empty dedicated test database because the
|
|
29
29
|
test helper applies the engine migration.
|
|
30
30
|
|
|
31
|
+
## Host application tests
|
|
32
|
+
|
|
33
|
+
Rails transactional tests keep the application connection inside an outer
|
|
34
|
+
transaction. Synchronous actor invocation deliberately rejects that condition
|
|
35
|
+
because nested savepoints retain actor locks until the test transaction ends
|
|
36
|
+
and make durable behavior unlike production.
|
|
37
|
+
|
|
38
|
+
Use an actor-specific base class:
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
require "solid_objects/test_helper"
|
|
42
|
+
|
|
43
|
+
class SolidObjectsTestCase < ActiveSupport::TestCase
|
|
44
|
+
include SolidObjects::TestHelper
|
|
45
|
+
end
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The helper disables transactional tests for that class and removes Solid
|
|
49
|
+
Objects instances and process registrations before and after each test. It
|
|
50
|
+
preserves application configuration, actor registration, and effect/commit
|
|
51
|
+
action registration. If actor commit actions create application records, clean
|
|
52
|
+
those records with fixtures or explicit teardown because they are no longer
|
|
53
|
+
covered by Rails' transaction rollback.
|
|
54
|
+
|
|
55
|
+
Use `drain_solid_objects` to process actor, reminder, effect, callback, and
|
|
56
|
+
broadcast work to a deterministic fixed point without arbitrary sleeps:
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
message = Counter.ref("test").async(:increment)
|
|
60
|
+
|
|
61
|
+
assert_equal 1, drain_solid_objects
|
|
62
|
+
assert_equal "completed", message.status
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Pass `roles: [:actors]` when a test intentionally wants to leave outboxes or
|
|
66
|
+
reminders pending.
|
|
67
|
+
|
|
68
|
+
`SolidObjects::TestHelper.reset_actors!` is also available for explicit suite
|
|
69
|
+
boundaries.
|
|
70
|
+
|
|
31
71
|
## Inline RBS
|
|
32
72
|
|
|
33
73
|
Ruby source starts with:
|
data/docs/fit.md
CHANGED
|
@@ -65,11 +65,19 @@ Each application process that performs synchronous calls registers one caller
|
|
|
65
65
|
process row. Actor state is rewritten as a JSON value on each successful
|
|
66
66
|
mutation.
|
|
67
67
|
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
Built-in bounded pruning is explicit and dry-run by default. Configure global
|
|
69
|
+
and per-actor-type message retention, then schedule the reviewed execute
|
|
70
|
+
commands. Actor instances expire only for types explicitly listed in
|
|
71
|
+
`instance_retention_by_actor_type`. See
|
|
70
72
|
[performance measurements](benchmarks.md) and
|
|
71
73
|
[retention guidance](operations.md#retention-and-backups).
|
|
72
74
|
|
|
75
|
+
Actor handlers may read application records but may not write them directly.
|
|
76
|
+
Use a same-database commit action for a short atomic database change or an
|
|
77
|
+
idempotent effect for external work. If the domain needs broad relational
|
|
78
|
+
updates throughout arbitrary handler code, an ordinary Active Record service
|
|
79
|
+
is likely a clearer fit.
|
|
80
|
+
|
|
73
81
|
## Decision checklist
|
|
74
82
|
|
|
75
83
|
Before adopting an actor, answer:
|
|
@@ -101,6 +101,12 @@ If the actor becomes authoritative before the legacy system is retired, emit a
|
|
|
101
101
|
transactional effect that updates the legacy store. The effect is at least once,
|
|
102
102
|
so the legacy write still needs idempotency.
|
|
103
103
|
|
|
104
|
+
When the legacy state is an application table in the same connection pool as
|
|
105
|
+
Solid Objects, a registered `commit_action` can update it in the fenced actor
|
|
106
|
+
transaction instead. Keep that action database-only and bounded. A separate
|
|
107
|
+
actor database cannot make this atomic; use the outbox effect and reconciliation
|
|
108
|
+
path there.
|
|
109
|
+
|
|
104
110
|
## 5. Cut over in reversible stages
|
|
105
111
|
|
|
106
112
|
A typical zero-downtime sequence is:
|
|
@@ -119,7 +125,8 @@ A typical zero-downtime sequence is:
|
|
|
119
125
|
|
|
120
126
|
Use a feature flag whose rollback restores legacy reads and writes without
|
|
121
127
|
requiring actor deletion. Do not assume a timed-out synchronous actor call did
|
|
122
|
-
not commit;
|
|
128
|
+
not commit; use `error.message_reference.wait` to reauthorize and recover the
|
|
129
|
+
durable result, or use an idempotency key before retrying.
|
|
123
130
|
|
|
124
131
|
## 6. Plan for dormant state and future changes
|
|
125
132
|
|