2424counter = Counter .ref(" global" )
2525count = counter.increment(amount: 5 )
2626current_count = counter.value
27+ current_snapshot = counter.snapshot.value
2728
2829# Durable fire-and-forget delivery. A worker processes it later.
2930message = 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
4749Direct methods and ` sync ` durably enqueue the call, then the Rails caller helps
4850execute the actor through the same mailbox, lease, and fencing path as a
4951worker. ` 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+
5160Before 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
346357State, arguments, results, effects, and reminder arguments accept
347358JSON-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+
349374Lifecycle hooks are also available:
350375
351376``` ruby
@@ -451,6 +476,37 @@ and MCP request/response boundaries when the handler itself fits the
451476application's latency budget. If another process owns the activation, the
452477caller waits for the durable result using wake-up hints with bounded database
453478polling 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
455511Actor code cannot use direct calls or ` sync ` on another actor; synchronous
456512actor-to-actor waits can deadlock in cycles. Use ` async ` or ` send_to ` and a
492548
493549External 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
497597Cloudflare 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
556656state 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
560664Destroy 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
665774bundle exec solid_objects status
666775bundle 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
667779bundle exec solid_objects dead_letters
668780bundle 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+
671786The supervisor stops new claims, drains active loops, releases cached leases,
672787and marks process rows stopped on graceful shutdown. A hard-killed worker's
673788claimed 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
837958Partially 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
850971Production readiness requires hardening and operational soak evidence. The
851972[ roadmap] ( docs/roadmap.md ) tracks that work.
0 commit comments