You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Copy file name to clipboardExpand all lines: docs/architecture.md
+41-5Lines changed: 41 additions & 5 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -72,7 +72,7 @@ through the client.
72
72
73
73
### Client and mailbox
74
74
75
-
The client finds or creates the actor instance and atomically allocates a sequence. It inserts one durable message-history row and one ready-membership row. It validates operations and JSON payloads before writing and enforces idempotency-key uniqueness, payload limits, and the per-actor mailbox cap. It also authorizes and coordinates actor destruction. Distributed rate limiting and global admission control are not implemented.
75
+
The client finds or creates the actor instance and atomically allocates a sequence. It inserts one durable message-history row and one ready-membership row. It validates operations and JSON payloads before writing and enforces idempotency-key uniqueness, payload limits, and the per-actor mailbox cap. It also authorizes and coordinates actor destruction. Distributed rate limiting and global admission control are not implemented and are not planned here.
76
76
77
77
Message execution state is table membership, not a status column. The durable message remains for results, retention, and diagnostics. Only live work occupies `ready_messages` or `claimed_messages`, so completed history cannot inflate the polling index.
78
78
@@ -424,6 +424,42 @@ outer commit, and callers timing out on work they indirectly block.
424
424
waiting and immediately returns a `MessageReference`. Runtime workers process
425
425
it normally.
426
426
427
+
A caller that loses that reference rebuilds one. `SolidObjects.client.find_by`
428
+
answers a request id, which is unique across the table, and
429
+
`reference.find_by` answers an idempotency key, which is unique per instance.
430
+
Each lookup runs the authorization hook the original call ran, against the
431
+
stored operation and arguments, and answers `nil` for an absent row, an
432
+
unregistered actor, and a refused caller alike, so it cannot be used to ask
433
+
whether a request id exists. `MessageReference#outcome` reports the status, the
434
+
result, the persisted error, the rejection, and the attempt count. A result is
435
+
stored for `sync` delivery only.
436
+
437
+
An actor remembers the idempotency keys of its own finished turns. The executor
438
+
already writes the instance row in the transaction that completes, rejects, or
439
+
kills a turn, so the memory rides on a write that happens anyway. This is the
440
+
Orleans answer: a grain keeps its deduplication history in grain state rather
441
+
than in a separate tombstone table, which needs no second store, no second
442
+
write, and no separate retention. `reference.find_by(idempotency_key:)` raises
443
+
`MessagePruned` for a key the actor remembers and whose message retention
444
+
removed, and answers `nil` for a key no caller ever sent, so a client can tell
445
+
a lost result from a request that never arrived. An actor remembers the operation and original arguments beside each key, so
446
+
the pruned answer runs the same hook against the same operation and arguments that a lookup
447
+
of the surviving row would, and a caller the policy refuses reads `nil` whether
448
+
the message is pruned or never existed. Gating it on `snapshot` instead would
449
+
tell a caller who may read state, but not the operation, that the operation had
450
+
run.
451
+
Remembered arguments count toward the serialized memory limit and remain until
452
+
the entry is evicted or the instance is removed. Entries from older versions
453
+
that lack arguments return absence after pruning because their original
454
+
authorization cannot be reproduced.
455
+
456
+
`retained_idempotency_keys` bounds the memory and defaults to 64 keys for each
457
+
actor, and `retained_idempotency_keys_bytes` bounds its serialized size at 16 KB,
458
+
because an idempotency key has no length limit on every adapter and the memory
459
+
outlives the message row. An actor drops its oldest keys until the list fits. Only a lookup by idempotency key can make the distinction. A request id
460
+
is generated by the runtime rather than by the caller, so no actor remembers
461
+
one, and `client.find_by(request_id:)` answers `nil` in both cases.
462
+
427
463
An executing caller receives an inline after-commit callback error even though
428
464
the turn committed. An independently waiting caller observes the durable
429
465
result and may return before that callback raises in the worker. Completed
@@ -750,11 +786,11 @@ Enqueue counts unfinished rows under the locked actor instance and rejects with
750
786
751
787
### Per-actor rate limits
752
788
753
-
The initial implementation supplies the mailbox cap. Distributed token buckets or time-window counters are a hardening milestone.
789
+
This runtime supplies the mailbox cap. Distributed token buckets and time-window counters are not planned here, because a request-path limiter is hot and loss-tolerant while every invocation writes one permanent message row. Solid Objects Pro answers that shape with grouped and ephemeral operations, which [fit](fit.md) describes.
754
790
755
791
### Global enqueue limits
756
792
757
-
Global admission hooks are not implemented. A future hook can reject based on database health or application policy without introducing a strict global counter as a contention hotspot.
793
+
Global admission hooks are not implemented and are not planned here, for the same reason as per-actor rate limits. A strict global counter would also be a contention hotspot. Reject on database health or application policy in front of the actor instead.
758
794
759
795
### Payload size
760
796
@@ -867,9 +903,9 @@ All backends use unique identity and sequence constraints, short transactions, a
867
903
12.**How are leases renewed?** Conditional database update by instance, owner, generation, and unexpired lease.
868
904
13.**How does graceful shutdown work?** Stop claims, finish current turn within timeout, release cached leases, stop heartbeat, mark process stopped.
869
905
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.
870
-
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.
906
+
15.**What happens after caller timeout?** A committed message continues and its eventual result can be recovered with the timeout's authorized message reference, or with `find_by` from the request id or the idempotency key when that reference is gone. An enqueue timeout leaves no message. Running Ruby code is not preempted.
871
907
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.
872
-
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.
908
+
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 are not planned here; Solid Objects Pro answers that shape.
873
909
18.**How are completed messages pruned?** Operators schedule the dry-run-reviewed `prune_messages --execute` command. Solid Objects does not run deletion automatically.
874
910
19.**How are state migrations performed?** Explicit one-step actor migrations on activation, persisted only with a successful fenced commit.
875
911
20.**What happens during rolling deploys?** Newer state can make old workers incompatible; deploys must preserve backward readability or drain old workers.
0 commit comments