Skip to content

Commit 3441db5

Browse files
committed
feat: harden actor transaction boundaries
Prevent actor code from leaking application writes and provide same-database commit actions for atomic domain changes. Bound synchronous database contention, preserve timeout diagnostics and result recovery, and add explicit snapshot, retention, and test tooling for production adoption.
1 parent 81a510d commit 3441db5

80 files changed

Lines changed: 3200 additions & 119 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,19 @@
11
# Changelog
22

3+
## Unreleased
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+
317
## 0.2.1 - 2026-08-06
418

519
- Add `solid_objects:doctor` for configuration, schema, policy, runtime, and

README.md

Lines changed: 125 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ end
2424
counter = Counter.ref("global")
2525
count = counter.increment(amount: 5)
2626
current_count = counter.value
27+
current_snapshot = counter.snapshot.value
2728

2829
# Durable fire-and-forget delivery. A worker processes it later.
2930
message = counter.async(:increment, amount: 5)
@@ -41,13 +42,21 @@ The invocation model is the first adoption decision:
4142
| --- | --- | --- |
4243
| `counter.increment(amount: 5)` | Committed handler result | No |
4344
| `counter.sync(:increment, amount: 5)` | Committed handler result | No |
44-
| `counter.value` | Deeply frozen state snapshot | No |
45+
| `counter.value` | Ordered, committed query result | No |
46+
| `counter.snapshot.value` | Current committed state without a mailbox message | No |
4547
| `counter.async(:increment, amount: 5)` | `MessageReference` immediately | Yes |
4648

4749
Direct methods and `sync` durably enqueue the call, then the Rails caller helps
4850
execute the actor through the same mailbox, lease, and fencing path as a
4951
worker. `async` only enqueues; a runtime process handles it later.
5052

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+
5160
Before adopting a latency-sensitive or high-volume surface, read
5261
[Is Solid Objects a good fit?](docs/fit.md) and the
5362
[measured performance and row-growth costs](docs/benchmarks.md).
@@ -69,6 +78,7 @@ but the project does not yet claim production readiness. See
6978
- [Defining an actor](#defining-an-actor)
7079
- [Actor identity](#actor-identity)
7180
- [Invoking an object](#invoking-an-object)
81+
- [Application database writes](#application-database-writes)
7282
- [Effects](#effects)
7383
- [Reminders](#reminders)
7484
- [Destroying an object](#destroying-an-object)
@@ -273,6 +283,7 @@ the runtime when the feature introduces asynchronous delivery or outboxes:
273283
| --- | --- |
274284
| Direct actor method or explicit `sync` | None; the caller executes it |
275285
| Attribute or declared query read | None; the caller executes it |
286+
| Committed `snapshot` read | None; reads the instance row directly |
276287
| `destroy` | None |
277288
| `async` including delayed delivery | Actor worker |
278289
| 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
346357
State, arguments, results, effects, and reminder arguments accept
347358
JSON-compatible values. Solid Objects never deserializes Ruby `Marshal` data.
348359

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+
349374
Lifecycle hooks are also available:
350375

351376
```ruby
@@ -451,6 +476,37 @@ and MCP request/response boundaries when the handler itself fits the
451476
application's latency budget. If another process owns the activation, the
452477
caller waits for the durable result using wake-up hints with bounded database
453478
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.
454510

455511
Actor code cannot use direct calls or `sync` on another actor; synchronous
456512
actor-to-actor waits can deadlock in cycles. Use `async` or `send_to` and a
@@ -492,6 +548,50 @@ end
492548

493549
External systems must also deduplicate effects using the stable effect ID.
494550

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+
495595
## Effects
496596

497597
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
555655
`.orphaned`, but every repair must go through `async`. Never bulk-update actor
556656
state around the lease and fencing checks.
557657

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+
558662
## Destroying an object
559663

560664
Destroy an actor incarnation through its reference:
@@ -631,6 +735,11 @@ Important defaults:
631735
| `max_attempts` | 5 |
632736
| `process_heartbeat_interval` | 15 seconds |
633737
| `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 |
634743
| `worker_count` | 1 |
635744
| `effect_worker_count` | 1 |
636745
| `broadcast_worker_count` | 1 |
@@ -664,10 +773,16 @@ Administration commands require the administration policy:
664773
```bash
665774
bundle exec solid_objects status
666775
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
667779
bundle exec solid_objects dead_letters
668780
bundle exec solid_objects retry_dead_letter 123
669781
```
670782

783+
The prune commands preview counts by default. Add `--execute` only after
784+
reviewing the configured retention policy.
785+
671786
The supervisor stops new claims, drains active loops, releases cached leases,
672787
and marks process rows stopped on graceful shutdown. A hard-killed worker's
673788
claimed turn is recovered after its process heartbeat or activation lease
@@ -819,6 +934,8 @@ Implemented and tested in 0.2:
819934
- Rails engine, install generator, migrations, and `solid_objects` executable;
820935
- actor registry, references, JSON state, and state migrations;
821936
- 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;
822939
- durable message history plus ready and claimed membership tables;
823940
- concurrent sequence allocation and actor creation;
824941
- activation leases, per-activation tokens, fencing generations, and
@@ -831,7 +948,11 @@ Implemented and tested in 0.2:
831948
- authorized actor destruction with fenced stale-write rejection and cascading
832949
durable-work cleanup;
833950
- durable observable broadcasts and authorized Action Cable refresh;
834-
- process registration, heartbeats, cleanup, and graceful shutdown; 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
835956
- SQLite, PostgreSQL, and MySQL integration tests.
836957

837958
Partially implemented:
@@ -844,8 +965,8 @@ Partially implemented:
844965
Turbo append actions remain future work;
845966
- local admission limits exist, but distributed rate limits and global
846967
admission control do not; and
847-
- administration views exist, but retention automation and richer audit tools
848-
do not.
968+
- administration views and pruning commands exist, but scheduled maintenance
969+
and richer audit tools do not.
849970

850971
Production readiness requires hardening and operational soak evidence. The
851972
[roadmap](docs/roadmap.md) tracks that work.

0 commit comments

Comments
 (0)