solid_objects 0.1.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 +7 -0
- data/CHANGELOG.md +17 -0
- data/MIT-LICENSE +19 -0
- data/README.md +744 -0
- data/Rakefile +40 -0
- data/app/controllers/solid_objects/application_controller.rb +23 -0
- data/app/controllers/solid_objects/dead_letters_controller.rb +23 -0
- data/app/controllers/solid_objects/instances_controller.rb +29 -0
- data/app/helpers/solid_objects/actor_helper.rb +25 -0
- data/app/models/solid_objects/broadcast.rb +10 -0
- data/app/models/solid_objects/claimed_message.rb +14 -0
- data/app/models/solid_objects/dead_letter.rb +13 -0
- data/app/models/solid_objects/effect.rb +10 -0
- data/app/models/solid_objects/instance.rb +93 -0
- data/app/models/solid_objects/message.rb +53 -0
- data/app/models/solid_objects/process.rb +13 -0
- data/app/models/solid_objects/ready_message.rb +10 -0
- data/app/models/solid_objects/record.rb +17 -0
- data/app/models/solid_objects/reminder.rb +9 -0
- data/app/views/solid_objects/dead_letters/index.html.erb +26 -0
- data/app/views/solid_objects/instances/index.html.erb +24 -0
- data/app/views/solid_objects/instances/show.html.erb +35 -0
- data/benchmark/activation_cache.rb +5 -0
- data/benchmark/ask_latency.rb +5 -0
- data/benchmark/claim.rb +5 -0
- data/benchmark/cold_actors.rb +5 -0
- data/benchmark/concurrent_actors.rb +5 -0
- data/benchmark/enqueue.rb +5 -0
- data/benchmark/hot_actor.rb +5 -0
- data/benchmark/processing.rb +5 -0
- data/benchmark/query_count.rb +5 -0
- data/benchmark/support.rb +271 -0
- data/config/routes.rb +8 -0
- data/db/migrate/20260805000000_create_solid_objects_tables.rb +319 -0
- data/docs/adr/0001-postgresql-backend.md +21 -0
- data/docs/adr/0002-jsonb-actor-state.md +21 -0
- data/docs/adr/0003-mailbox-ordering.md +30 -0
- data/docs/adr/0004-activation-leasing.md +21 -0
- data/docs/adr/0005-fencing-tokens.md +25 -0
- data/docs/adr/0006-at-least-once-delivery.md +24 -0
- data/docs/adr/0007-transactional-outbox.md +21 -0
- data/docs/adr/0008-actor-communication.md +21 -0
- data/docs/adr/0009-realtime-updates.md +21 -0
- data/docs/adr/0010-state-versioning.md +29 -0
- data/docs/adr/0011-wake-up-strategy.md +34 -0
- data/docs/adr/0012-not-active-jobs.md +21 -0
- data/docs/adr/0013-database-adapters.md +48 -0
- data/docs/architecture.md +615 -0
- data/docs/benchmarks.md +26 -0
- data/docs/correctness.md +124 -0
- data/docs/database-schema.md +111 -0
- data/docs/development.md +87 -0
- data/docs/implementation-plan.md +518 -0
- data/docs/operations.md +123 -0
- data/docs/realtime.md +51 -0
- data/docs/research/solid_queue.md +545 -0
- data/docs/roadmap.md +53 -0
- data/docs/security.md +61 -0
- data/docs/state-migrations.md +46 -0
- data/examples/application/README.md +16 -0
- data/examples/application/app/actors/chat_room_actor.rb +34 -0
- data/examples/application/app/actors/shopping_cart_actor.rb +79 -0
- data/examples/application/app/controllers/cart_controller.rb +54 -0
- data/examples/application/app/controllers/chat_rooms_controller.rb +44 -0
- data/examples/application/app/views/actors/chat_room_actor/_messages.html.erb +8 -0
- data/examples/application/app/views/actors/shopping_cart_actor/_summary.html.erb +10 -0
- data/examples/application/app/views/cart/show.html.erb +13 -0
- data/examples/application/app/views/chat_rooms/show.html.erb +8 -0
- data/examples/application/config/initializers/solid_objects.rb +23 -0
- data/examples/application/config/routes.rb +20 -0
- data/exe/solid_objects +9 -0
- data/lib/generators/solid_objects/install_generator.rb +21 -0
- data/lib/generators/solid_objects/templates/solid_objects.rb +13 -0
- data/lib/solid_objects/action_cable_broadcast_adapter.rb +19 -0
- data/lib/solid_objects/activation.rb +183 -0
- data/lib/solid_objects/activation_manager.rb +102 -0
- data/lib/solid_objects/actor.rb +271 -0
- data/lib/solid_objects/actor_channel.rb +29 -0
- data/lib/solid_objects/actor_definition.rb +212 -0
- data/lib/solid_objects/actor_registry.rb +65 -0
- data/lib/solid_objects/actor_snapshot.rb +42 -0
- data/lib/solid_objects/actor_view.rb +117 -0
- data/lib/solid_objects/broadcast_executor.rb +162 -0
- data/lib/solid_objects/cli.rb +118 -0
- data/lib/solid_objects/client.rb +153 -0
- data/lib/solid_objects/configuration.rb +168 -0
- data/lib/solid_objects/context.rb +41 -0
- data/lib/solid_objects/database_adapter.rb +82 -0
- data/lib/solid_objects/database_adapters/mysql.rb +22 -0
- data/lib/solid_objects/database_adapters/postgresql.rb +17 -0
- data/lib/solid_objects/database_adapters/sqlite.rb +12 -0
- data/lib/solid_objects/dead_letter_manager.rb +47 -0
- data/lib/solid_objects/dom_identity.rb +38 -0
- data/lib/solid_objects/effect_executor.rb +235 -0
- data/lib/solid_objects/effect_registry.rb +34 -0
- data/lib/solid_objects/engine.rb +33 -0
- data/lib/solid_objects/errors.rb +65 -0
- data/lib/solid_objects/executor.rb +290 -0
- data/lib/solid_objects/instrumentation.rb +10 -0
- data/lib/solid_objects/lease.rb +172 -0
- data/lib/solid_objects/lease_renewer.rb +70 -0
- data/lib/solid_objects/log_subscriber.rb +29 -0
- data/lib/solid_objects/mailbox.rb +178 -0
- data/lib/solid_objects/message_reference.rb +52 -0
- data/lib/solid_objects/process_registry.rb +143 -0
- data/lib/solid_objects/reference.rb +96 -0
- data/lib/solid_objects/reminder_scheduler.rb +168 -0
- data/lib/solid_objects/serialization.rb +99 -0
- data/lib/solid_objects/state.rb +111 -0
- data/lib/solid_objects/stream_name.rb +29 -0
- data/lib/solid_objects/stream_token.rb +59 -0
- data/lib/solid_objects/supervisor.rb +87 -0
- data/lib/solid_objects/turbo_stream_renderer.rb +35 -0
- data/lib/solid_objects/version.rb +5 -0
- data/lib/solid_objects/wake_up.rb +28 -0
- data/lib/solid_objects/worker.rb +139 -0
- data/lib/solid_objects.rb +118 -0
- data/sig/generated/controllers/solid_objects/application_controller.rbs +10 -0
- data/sig/generated/controllers/solid_objects/dead_letters_controller.rbs +11 -0
- data/sig/generated/controllers/solid_objects/instances_controller.rbs +11 -0
- data/sig/generated/helpers/solid_objects/actor_helper.rbs +8 -0
- data/sig/generated/lib/generators/solid_objects/install_generator.rbs +13 -0
- data/sig/generated/lib/solid_objects/action_cable_broadcast_adapter.rbs +8 -0
- data/sig/generated/lib/solid_objects/activation.rbs +65 -0
- data/sig/generated/lib/solid_objects/activation_manager.rbs +36 -0
- data/sig/generated/lib/solid_objects/actor.rbs +183 -0
- data/sig/generated/lib/solid_objects/actor_channel.rbs +8 -0
- data/sig/generated/lib/solid_objects/actor_definition.rbs +117 -0
- data/sig/generated/lib/solid_objects/actor_registry.rbs +36 -0
- data/sig/generated/lib/solid_objects/actor_snapshot.rbs +28 -0
- data/sig/generated/lib/solid_objects/actor_view.rbs +56 -0
- data/sig/generated/lib/solid_objects/broadcast_executor.rbs +55 -0
- data/sig/generated/lib/solid_objects/cli.rbs +31 -0
- data/sig/generated/lib/solid_objects/client.rbs +35 -0
- data/sig/generated/lib/solid_objects/configuration.rbs +147 -0
- data/sig/generated/lib/solid_objects/context.rbs +56 -0
- data/sig/generated/lib/solid_objects/database_adapter.rbs +42 -0
- data/sig/generated/lib/solid_objects/database_adapters/mysql.rbs +16 -0
- data/sig/generated/lib/solid_objects/database_adapters/postgresql.rbs +13 -0
- data/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs +10 -0
- data/sig/generated/lib/solid_objects/dead_letter_manager.rbs +16 -0
- data/sig/generated/lib/solid_objects/dom_identity.rbs +20 -0
- data/sig/generated/lib/solid_objects/effect_executor.rbs +82 -0
- data/sig/generated/lib/solid_objects/effect_registry.rbs +24 -0
- data/sig/generated/lib/solid_objects/engine.rbs +7 -0
- data/sig/generated/lib/solid_objects/errors.rbs +64 -0
- data/sig/generated/lib/solid_objects/executor.rbs +60 -0
- data/sig/generated/lib/solid_objects/instrumentation.rbs +8 -0
- data/sig/generated/lib/solid_objects/lease.rbs +57 -0
- data/sig/generated/lib/solid_objects/lease_renewer.rbs +42 -0
- data/sig/generated/lib/solid_objects/log_subscriber.rbs +11 -0
- data/sig/generated/lib/solid_objects/mailbox.rbs +46 -0
- data/sig/generated/lib/solid_objects/message_reference.rbs +37 -0
- data/sig/generated/lib/solid_objects/process_registry.rbs +46 -0
- data/sig/generated/lib/solid_objects/reference.rbs +45 -0
- data/sig/generated/lib/solid_objects/reminder_scheduler.rbs +55 -0
- data/sig/generated/lib/solid_objects/serialization.rbs +31 -0
- data/sig/generated/lib/solid_objects/state.rbs +72 -0
- data/sig/generated/lib/solid_objects/stream_name.rbs +11 -0
- data/sig/generated/lib/solid_objects/stream_token.rbs +19 -0
- data/sig/generated/lib/solid_objects/supervisor.rbs +38 -0
- data/sig/generated/lib/solid_objects/turbo_stream_renderer.rbs +14 -0
- data/sig/generated/lib/solid_objects/version.rbs +5 -0
- data/sig/generated/lib/solid_objects/wake_up.rbs +24 -0
- data/sig/generated/lib/solid_objects/worker.rbs +56 -0
- data/sig/generated/lib/solid_objects.rbs +41 -0
- data/sig/generated/models/solid_objects/broadcast.rbs +6 -0
- data/sig/generated/models/solid_objects/claimed_message.rbs +6 -0
- data/sig/generated/models/solid_objects/dead_letter.rbs +6 -0
- data/sig/generated/models/solid_objects/effect.rbs +6 -0
- data/sig/generated/models/solid_objects/instance.rbs +25 -0
- data/sig/generated/models/solid_objects/message.rbs +22 -0
- data/sig/generated/models/solid_objects/process.rbs +6 -0
- data/sig/generated/models/solid_objects/ready_message.rbs +6 -0
- data/sig/generated/models/solid_objects/record.rbs +8 -0
- data/sig/generated/models/solid_objects/reminder.rbs +6 -0
- data/sig/support/framework.rbs +37 -0
- metadata +467 -0
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
# Solid Objects Architecture
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
Solid Objects ports the Cloudflare Durable Objects programming model to Rails.
|
|
6
|
+
It is a database-backed virtual actor runtime for MySQL, PostgreSQL, and
|
|
7
|
+
SQLite. A virtual actor is a logical object addressed by type and ID whose
|
|
8
|
+
in-memory activation is created on demand, processes one mailbox turn at a
|
|
9
|
+
time, persists JSON state, and can disappear when idle without losing its
|
|
10
|
+
identity or state. This ports the programming model, not Cloudflare's
|
|
11
|
+
serverless runtime, global placement, storage API, or platform guarantees.
|
|
12
|
+
|
|
13
|
+
The runtime contract is:
|
|
14
|
+
|
|
15
|
+
> Messages for one actor are durably enqueued and processed sequentially, at least once, by at most one valid activation lease holder at a time.
|
|
16
|
+
|
|
17
|
+
This is not an exactly-once system. Different actor identities can execute concurrently.
|
|
18
|
+
|
|
19
|
+
## Boundaries
|
|
20
|
+
|
|
21
|
+
Solid Objects owns:
|
|
22
|
+
|
|
23
|
+
- Actor type registration and logical references
|
|
24
|
+
- Durable mailbox ordering
|
|
25
|
+
- Actor state serialization and version migration
|
|
26
|
+
- Activation leasing and fencing
|
|
27
|
+
- Message retries and dead letters
|
|
28
|
+
- Effects, reminders, and broadcast outboxes
|
|
29
|
+
- Worker process registration and supervision
|
|
30
|
+
- Rails instrumentation and optional realtime integration
|
|
31
|
+
|
|
32
|
+
The host application owns:
|
|
33
|
+
|
|
34
|
+
- Authentication and authorization policy
|
|
35
|
+
- Actor class definitions
|
|
36
|
+
- Effect handler implementation and idempotency
|
|
37
|
+
- Deployment compatibility of actor code and state
|
|
38
|
+
- Database capacity, backups, and monitoring
|
|
39
|
+
- Action Cable production adapter and topology
|
|
40
|
+
|
|
41
|
+
## Database coordination adapters
|
|
42
|
+
|
|
43
|
+
Solid Objects supports PostgreSQL 14+, MySQL 8.0+ with InnoDB, and SQLite 3.35+.
|
|
44
|
+
|
|
45
|
+
One adapter capability object is selected from the Active Record connection. It supplies claim locking and database-time expressions. Unsupported adapter families fail when first used. Minimum server-version and storage-engine checks are documented operating requirements; automatic boot-time enforcement and classified contention retries remain hardening work.
|
|
46
|
+
|
|
47
|
+
PostgreSQL is the high-concurrency reference backend. MySQL uses the same row-lock claiming shape but requires careful indexed scans to avoid excessive next-key locks. SQLite is correct for the same public contract but is operationally suited to development and modest single-host workloads because every write transaction serializes at the database-file level.
|
|
48
|
+
|
|
49
|
+
Adapter-specific implementation is not permitted to weaken fencing. A stale owner must fail the same conditional commit on every backend.
|
|
50
|
+
|
|
51
|
+
## Components
|
|
52
|
+
|
|
53
|
+
### Actor registry
|
|
54
|
+
|
|
55
|
+
The registry maps a stable persisted actor type string to a Ruby actor class. Registration rejects duplicate names and invalid classes. Runtime dispatch never constantizes a database value.
|
|
56
|
+
|
|
57
|
+
### Actor reference
|
|
58
|
+
|
|
59
|
+
A reference contains actor type and normalized actor ID. It is cheap,
|
|
60
|
+
serializable as data, and does not imply an active Ruby object. Declared
|
|
61
|
+
message methods delegate to `tell`; declared query and attribute methods
|
|
62
|
+
delegate to `ask`. `destroy` is a reserved synchronous reference operation.
|
|
63
|
+
All three paths authorize through the client.
|
|
64
|
+
|
|
65
|
+
### Client and mailbox
|
|
66
|
+
|
|
67
|
+
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 message names 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.
|
|
68
|
+
|
|
69
|
+
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.
|
|
70
|
+
|
|
71
|
+
### Activation lease
|
|
72
|
+
|
|
73
|
+
An activation lease is stored on the actor instance:
|
|
74
|
+
|
|
75
|
+
- Owner process UUID
|
|
76
|
+
- Database-time expiration
|
|
77
|
+
- Monotonic generation
|
|
78
|
+
|
|
79
|
+
Acquisition and renewal are short database writes. Generation is the fencing token used by every state commit.
|
|
80
|
+
|
|
81
|
+
### Activation
|
|
82
|
+
|
|
83
|
+
An activation is an in-memory actor object, its state, its current lease, and last-used time. It processes the earliest nonterminal sequence represented in the ready or claimed membership tables. It can drain a bounded number of messages before yielding.
|
|
84
|
+
|
|
85
|
+
### Worker
|
|
86
|
+
|
|
87
|
+
A worker:
|
|
88
|
+
|
|
89
|
+
1. Registers and starts heartbeating.
|
|
90
|
+
2. Selects candidate actors with due work.
|
|
91
|
+
3. Claims an activation through the configured database coordination adapter.
|
|
92
|
+
4. Loads and migrates state.
|
|
93
|
+
5. Runs one message turn outside a database transaction.
|
|
94
|
+
6. Commits state, completion, result, and outboxes in a short fenced transaction.
|
|
95
|
+
7. Renews the lease when necessary.
|
|
96
|
+
8. Yields after configured message or duration limits.
|
|
97
|
+
9. Keeps or releases the activation according to idle policy.
|
|
98
|
+
10. Stops claiming during graceful shutdown and releases owned leases after turns finish.
|
|
99
|
+
|
|
100
|
+
### Supervisor
|
|
101
|
+
|
|
102
|
+
The supervisor starts configured worker, effect, reminder, and broadcast thread roles and coordinates graceful shutdown. It does not yet replace a failed role or periodically prune stale process records; operators run the cleanup command separately. Database leases and fencing, rather than thread supervision, provide correctness.
|
|
103
|
+
|
|
104
|
+
### Effect worker
|
|
105
|
+
|
|
106
|
+
An effect worker claims due effect rows through the database coordination adapter, invokes a registered handler outside a database transaction, then records success or retryable failure. The handler receives the effect UUID as its idempotency key. Optional outcome messages are normal actor mailbox messages.
|
|
107
|
+
|
|
108
|
+
### Reminder scheduler
|
|
109
|
+
|
|
110
|
+
The scheduler claims due reminder definitions, locks the source actor instance,
|
|
111
|
+
then enqueues the ordinary actor message and advances or completes the reminder
|
|
112
|
+
in one transaction. A unique occurrence key prevents two schedulers from
|
|
113
|
+
producing two mailbox rows for the same reminder occurrence. Locking the source
|
|
114
|
+
instance first prevents a claimed reminder from recreating a destroyed actor.
|
|
115
|
+
|
|
116
|
+
### Broadcast worker
|
|
117
|
+
|
|
118
|
+
The broadcast worker claims committed observable-change rows, renders idempotent Turbo replacements, broadcasts to a signed actor stream, and records delivery. Current actor state remains the reconnect source of truth.
|
|
119
|
+
|
|
120
|
+
### Process registry
|
|
121
|
+
|
|
122
|
+
Every runtime role has a UUID process row containing kind, hostname, PID, start and heartbeat times, metadata, and shutdown state. Heartbeats run independently from work loops.
|
|
123
|
+
|
|
124
|
+
## Actor definition model
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
class OrderActor < SolidObjects::Actor
|
|
128
|
+
actor_type "orders"
|
|
129
|
+
|
|
130
|
+
attribute :status, default: "draft"
|
|
131
|
+
|
|
132
|
+
def submit
|
|
133
|
+
self.status = "submitted"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
observable :status
|
|
137
|
+
end
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
`attribute` creates actor instance readers and writers and an ordered read query.
|
|
141
|
+
Public instance methods declared on the actor are messages. Declare helpers as
|
|
142
|
+
private or protected. Messages and queries are exposed as methods on a
|
|
143
|
+
reference: message methods delegate to `tell`, while query and attribute
|
|
144
|
+
methods delegate to `ask`. Returned state snapshots are deeply frozen. The
|
|
145
|
+
explicit `message` DSL remains available for dynamic definitions.
|
|
146
|
+
|
|
147
|
+
`message` and `query` both execute as durable mailbox turns. A query may not
|
|
148
|
+
mutate state. The executor detects query mutation and fails the message. An
|
|
149
|
+
observable is a named projection of state used by server rendering and realtime
|
|
150
|
+
updates; it is not independently persisted.
|
|
151
|
+
|
|
152
|
+
Lifecycle hooks are deterministic local hooks:
|
|
153
|
+
|
|
154
|
+
- `on_activate` runs after state load and migration. State changes made there are included with the next successful message commit, not persisted on activation alone.
|
|
155
|
+
- `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.
|
|
156
|
+
|
|
157
|
+
Durable application cleanup belongs in messages, reminders, or effects.
|
|
158
|
+
|
|
159
|
+
## Enqueue and sequence allocation
|
|
160
|
+
|
|
161
|
+
Enqueue uses one transaction:
|
|
162
|
+
|
|
163
|
+
1. Resolve actor class from the registry.
|
|
164
|
+
2. Validate authorization, message name, actor ID, arguments, and size.
|
|
165
|
+
3. `INSERT ... ON CONFLICT` the actor instance if missing.
|
|
166
|
+
4. Lock the instance row.
|
|
167
|
+
5. Enforce the mailbox limit.
|
|
168
|
+
6. Read and increment `next_message_sequence`.
|
|
169
|
+
7. Insert the durable message with that sequence.
|
|
170
|
+
8. Insert its ready-membership row.
|
|
171
|
+
9. Commit.
|
|
172
|
+
10. Emit instrumentation and a non-durable wake-up hint.
|
|
173
|
+
|
|
174
|
+
The increment and insert roll back together. The unique index on `(actor_type, actor_id, sequence)` is the final invariant. Concurrent first access is resolved by the unique actor identity index and retrying the instance lookup.
|
|
175
|
+
|
|
176
|
+
Committed concurrent enqueues have one database-defined sequence order. No order is promised between transactions that have not committed.
|
|
177
|
+
|
|
178
|
+
## Actor destruction
|
|
179
|
+
|
|
180
|
+
`ActorClass.ref(actor_id).destroy` is a synchronous, idempotent runtime
|
|
181
|
+
operation. It is forbidden from actor context and has a separate
|
|
182
|
+
`authorize_destroy` policy that runs before actor existence is revealed.
|
|
183
|
+
|
|
184
|
+
Destruction uses one transaction:
|
|
185
|
+
|
|
186
|
+
1. Resolve the actor type through the registry.
|
|
187
|
+
2. Authorize the actor type and ID.
|
|
188
|
+
3. Lock the actor instance by logical identity.
|
|
189
|
+
4. Return `false` if it does not exist.
|
|
190
|
+
5. Delete the instance.
|
|
191
|
+
6. Let foreign-key cascades delete message history, ready and claimed
|
|
192
|
+
memberships, dead letters, reminders, effects, and broadcasts.
|
|
193
|
+
7. Commit, emit `solid_objects.actor.destroyed`, and wake local waiters.
|
|
194
|
+
|
|
195
|
+
The instance primary key is the actor-incarnation boundary. A worker holding an
|
|
196
|
+
old lease can continue running Ruby code, but its fenced transaction cannot
|
|
197
|
+
find the deleted instance and raises `LostActivation`. If the same logical
|
|
198
|
+
identity is referenced later, enqueue creates a new instance with default
|
|
199
|
+
state, state version, sequence 1, and a new primary key. An enqueue that loses
|
|
200
|
+
the instance between lookup and locking retries against the new incarnation.
|
|
201
|
+
|
|
202
|
+
A claimed reminder locks the source instance before enqueueing its occurrence,
|
|
203
|
+
so it either commits before destruction and is deleted by the cascade, or
|
|
204
|
+
observes the missing instance and does nothing. An already-running external
|
|
205
|
+
effect, actor-to-actor delivery, or broadcast may have crossed the database
|
|
206
|
+
boundary before destruction; it cannot be recalled. Its completion sees the
|
|
207
|
+
deleted outbox row and cannot enqueue a callback or recreate the source actor.
|
|
208
|
+
|
|
209
|
+
## Candidate selection and fairness
|
|
210
|
+
|
|
211
|
+
An actor is eligible when:
|
|
212
|
+
|
|
213
|
+
- Its activation is unowned or expired.
|
|
214
|
+
- Its earliest ready message is due and no earlier claimed message exists.
|
|
215
|
+
- It is not administratively paused.
|
|
216
|
+
|
|
217
|
+
Candidate discovery is a read-only grouped scan of the narrow ready and claimed membership tables, ordered by each actor's earliest due or stale claimed work. For each candidate, PostgreSQL and MySQL attempt a primary-key instance-row lock with `FOR UPDATE SKIP LOCKED`, then recheck lease eligibility under that lock. This avoids MySQL next-key locking across a broad joined scan. SQLite performs the same short claim through Active Record's immediate write transaction and serializes writers at the database level.
|
|
218
|
+
|
|
219
|
+
When an activation exhausts its pass budget, the worker releases its lease and sets its already-due ready memberships to the current database time. Their durable message availability is unchanged. Actors that have been waiting longer therefore precede the yielded hot actor on the next grouped scan.
|
|
220
|
+
|
|
221
|
+
Within one activation pass, the worker stops after either:
|
|
222
|
+
|
|
223
|
+
- `max_messages_per_activation_pass`
|
|
224
|
+
- `max_activation_duration`
|
|
225
|
+
- No due earliest message
|
|
226
|
+
- Lease loss
|
|
227
|
+
- Shutdown request
|
|
228
|
+
|
|
229
|
+
The actor can be cached until `idle_deactivation_timeout`, and the worker continues renewing its lease while it is cached. The initial cache has no separate capacity limit; bounded cache eviction is a hardening milestone. Idle release runs the nondurable deactivation hook and conditionally releases the lease.
|
|
230
|
+
|
|
231
|
+
## Message execution
|
|
232
|
+
|
|
233
|
+
Before actor code:
|
|
234
|
+
|
|
235
|
+
1. Renew if the lease would expire before the next renewal window.
|
|
236
|
+
2. Load the earliest nonterminal mailbox sequence.
|
|
237
|
+
3. Atomically move its ready membership to claimed membership for the current owner and generation and increment the durable attempt counter.
|
|
238
|
+
4. Set the actor's current message context.
|
|
239
|
+
5. Snapshot state and observable values.
|
|
240
|
+
|
|
241
|
+
Actor code then executes with no open database transaction and no pinned connection. It can:
|
|
242
|
+
|
|
243
|
+
- Read and mutate its in-memory state for a message
|
|
244
|
+
- Read state for a query
|
|
245
|
+
- Stage effects
|
|
246
|
+
- Stage reminders
|
|
247
|
+
- Stage asynchronous actor messages
|
|
248
|
+
|
|
249
|
+
It cannot:
|
|
250
|
+
|
|
251
|
+
- Call `ask` from actor context
|
|
252
|
+
- Perform a synchronous actor-to-actor wait
|
|
253
|
+
- Assume execution happens once
|
|
254
|
+
- Commit actor state directly
|
|
255
|
+
|
|
256
|
+
After actor code, the executor validates state and staged data as JSON and computes changed observables.
|
|
257
|
+
|
|
258
|
+
## Fenced commit
|
|
259
|
+
|
|
260
|
+
Successful completion uses one database transaction:
|
|
261
|
+
|
|
262
|
+
1. Lock the instance row.
|
|
263
|
+
2. Verify owner, generation, and an unexpired lease using database time.
|
|
264
|
+
3. Lock the durable message and verify its claimed membership belongs to that owner and generation.
|
|
265
|
+
4. Update native JSON state and state version.
|
|
266
|
+
5. Store the completion timestamp and result on the durable message and delete claimed membership.
|
|
267
|
+
6. Insert staged effects.
|
|
268
|
+
7. Insert or update staged reminders.
|
|
269
|
+
8. Insert staged actor-message outbox rows.
|
|
270
|
+
9. Insert changed-observable broadcast rows.
|
|
271
|
+
10. Update actor last-used time.
|
|
272
|
+
11. Commit.
|
|
273
|
+
|
|
274
|
+
Any lease or message predicate failure raises `LostActivation` and rolls back every item. The stale worker discards its in-memory activation.
|
|
275
|
+
|
|
276
|
+
The state version can advance because of state migration even when the message itself makes no state change.
|
|
277
|
+
|
|
278
|
+
## Failure path
|
|
279
|
+
|
|
280
|
+
Actor exceptions roll back all in-memory changes by restoring the pre-turn state. A separate short transaction conditionally owned by the current generation:
|
|
281
|
+
|
|
282
|
+
- Stores a sanitized error
|
|
283
|
+
- Deletes claimed membership
|
|
284
|
+
- Reinserts ready membership with retry availability using backoff
|
|
285
|
+
- Or creates a dead letter without reinserting ready membership
|
|
286
|
+
|
|
287
|
+
If the lease was lost, even failure finalization is abandoned. The new activation recovers the stale claimed membership.
|
|
288
|
+
|
|
289
|
+
The default ordering policy is strict:
|
|
290
|
+
|
|
291
|
+
- A retryable failed message blocks later messages.
|
|
292
|
+
- After it exceeds the retry limit and becomes dead, the next sequence may run.
|
|
293
|
+
- Operators can inspect and retry a dead letter.
|
|
294
|
+
- Retrying creates a new message at the tail; it does not rewrite history or jump ahead.
|
|
295
|
+
|
|
296
|
+
## Crash scenarios
|
|
297
|
+
|
|
298
|
+
### Worker dies before starting actor code
|
|
299
|
+
|
|
300
|
+
The activation lease eventually expires. A new worker advances the generation, recovers the earliest stale claimed membership, and executes it.
|
|
301
|
+
|
|
302
|
+
### Worker dies during actor code
|
|
303
|
+
|
|
304
|
+
No actor transaction was open. Its computed state is lost. After lease expiry, another worker executes the message again with the previously committed state.
|
|
305
|
+
|
|
306
|
+
### Worker dies during commit before the database commits
|
|
307
|
+
|
|
308
|
+
The database rolls back the transaction. The message executes again after lease recovery.
|
|
309
|
+
|
|
310
|
+
### Worker dies after commit but before local acknowledgement
|
|
311
|
+
|
|
312
|
+
The completed message and new state are already durable. The replacement activation skips the completed sequence. The original actor code is not rerun for that message.
|
|
313
|
+
|
|
314
|
+
### Worker pauses, lease expires, and another worker commits
|
|
315
|
+
|
|
316
|
+
The replacement has a higher generation. When the paused worker resumes, its conditional commit fails and all of its state and outbox changes roll back.
|
|
317
|
+
|
|
318
|
+
### Effect worker dies after external success but before recording success
|
|
319
|
+
|
|
320
|
+
The effect can be delivered again. The stable effect ID is the idempotency key. This is why effect handlers must be idempotent.
|
|
321
|
+
|
|
322
|
+
## Ask
|
|
323
|
+
|
|
324
|
+
`ask` durably enqueues a normal message with a request ID, then waits for the row to become completed, dead-lettered, or timed out. Every waiter re-queries durable rows. The implemented wake-up interface provides same-process signaling, bounded polling, and dependency injection. PostgreSQL `LISTEN/NOTIFY` and optional Redis Pub/Sub are planned adapters.
|
|
325
|
+
|
|
326
|
+
With a healthy cross-process wake-up adapter, the coordination-overhead target from enqueue or completion commit to the confirming query is p99 at or below 100 milliseconds. End-to-end latency also includes mailbox queueing and handler execution. Polling-only deployments can pay the configured interval on both the worker and result legs, so polling-only `ask` is for background callers, scripts, and control paths rather than latency-sensitive Rails request handlers.
|
|
327
|
+
|
|
328
|
+
Caller timeout:
|
|
329
|
+
|
|
330
|
+
- Raises `SolidObjects::AskTimeout`.
|
|
331
|
+
- Does not cancel or delete the message.
|
|
332
|
+
- Does not prevent later execution.
|
|
333
|
+
- Leaves the result available until retention cleanup.
|
|
334
|
+
|
|
335
|
+
The durable row remains after timeout and can be inspected directly by message ID. A public request-ID lookup and bounded retention commands are not yet implemented.
|
|
336
|
+
|
|
337
|
+
## Effects and actor-to-actor delivery
|
|
338
|
+
|
|
339
|
+
`emit` creates a staged effect:
|
|
340
|
+
|
|
341
|
+
```ruby
|
|
342
|
+
emit(
|
|
343
|
+
:charge_payment,
|
|
344
|
+
payment_id:,
|
|
345
|
+
amount_cents: total_cents,
|
|
346
|
+
on_success: :payment_charged,
|
|
347
|
+
on_failure: :payment_failed
|
|
348
|
+
)
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
An effect handler receives JSON arguments plus an effect context. Outcome messages include effect ID and a safe result or error summary.
|
|
352
|
+
|
|
353
|
+
Actor code sends to another actor through a staged outbox:
|
|
354
|
+
|
|
355
|
+
```ruby
|
|
356
|
+
send_to InventoryActor.ref("sku-123"), :reserve, order_id: id, quantity: 2
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
The source actor commit never waits for the target. The outbox worker allocates the target's sequence after source commit. There is no global order across actors.
|
|
360
|
+
|
|
361
|
+
## Reminders
|
|
362
|
+
|
|
363
|
+
A reminder record contains actor identity, a reminder name, target message, JSON arguments, next run time, optional interval, status, and occurrence counter.
|
|
364
|
+
|
|
365
|
+
```ruby
|
|
366
|
+
schedule :expire, at: 30.minutes.from_now, arguments: {}
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
When due, the scheduler locks the source instance and creates a normal mailbox
|
|
370
|
+
row with an idempotency key derived from reminder ID and occurrence. The
|
|
371
|
+
mailbox insert and reminder advancement commit atomically. The mailbox provides
|
|
372
|
+
sequential processing and ordinary retry behavior.
|
|
373
|
+
|
|
374
|
+
Solid Objects persists each occurrence by its mailbox row. Unlike Orleans reminders, an outage does not intentionally discard a due occurrence. Recurring catch-up is configurable:
|
|
375
|
+
|
|
376
|
+
- `:latest` enqueues the current occurrence and advances beyond the current time.
|
|
377
|
+
- `:all` enqueues one occurrence per scheduler pass and advances one interval, allowing bounded catch-up through ordinary scheduler work.
|
|
378
|
+
|
|
379
|
+
### Schedule reconciliation
|
|
380
|
+
|
|
381
|
+
Durable reminders are alarms, and an alarm can be lost at the application level even while the database and actor state remain healthy. A reminder callback may dead-letter, a handler may fail to schedule its successor, or a signup/configuration path may never enqueue the first message. Unlike a periodic full sweep, a self-scheduling actor can then remain silently inert forever.
|
|
382
|
+
|
|
383
|
+
Applications with self-scheduling actors should run a lower-frequency reconciliation job. The reconciler may read actor state and report drift, but it never writes actor state directly. Every repair is a normal authorized `tell`, so the actor decides whether the transition is still necessary and all ordering, lease, fencing, and audit rules remain intact.
|
|
384
|
+
|
|
385
|
+
`SolidObjects::Instance` exposes batchable read relations:
|
|
386
|
+
|
|
387
|
+
- `.active(actor_type:)`
|
|
388
|
+
- `.without_pending_work(quiet_for:)`, excluding actors with ready messages, claimed messages, or scheduled reminders
|
|
389
|
+
- `.orphaned(actor_type:, owner:)`, comparing actor IDs with the owner relation's primary key
|
|
390
|
+
|
|
391
|
+
The expected drift categories are actors with a lost alarm, missing actors for live owners, configuration drift, and actors whose owners were deleted. Suspended actors should be reported rather than automatically resumed unless the application defines a separate, deliberately slower recovery policy. Reconciliation metrics are the primary diagnostic: revived actors should be treated as evidence of lost alarms, while persistent bootstrap, reconfiguration, or orphan counts point to lifecycle bugs.
|
|
392
|
+
|
|
393
|
+
Bulk repair updates to `solid_objects_instances` are forbidden. They bypass activation ownership and fencing and can overwrite a concurrently committed actor state. Direct reads are observational; writes go through actor messages.
|
|
394
|
+
|
|
395
|
+
Large repairs use `tell(..., available_at:)` to spread work over an application-defined dispatch window. The durable message records the requested availability and ready membership drives the hot polling query. This prevents reconciliation from flooding mailboxes and starving normal traffic.
|
|
396
|
+
|
|
397
|
+
## Realtime integration
|
|
398
|
+
|
|
399
|
+
`solid_object` performs an authorized state read for initial rendering and emits:
|
|
400
|
+
|
|
401
|
+
- A stable scope DOM ID derived from actor type and a SHA-256 digest of actor ID
|
|
402
|
+
- One Turbo Cable subscription element for the actor
|
|
403
|
+
- Stable child target IDs for values and components
|
|
404
|
+
- A signed actor token used by the channel subscription
|
|
405
|
+
|
|
406
|
+
```erb
|
|
407
|
+
<%= solid_object current_cart do |cart| %>
|
|
408
|
+
Cart items: <%= cart.items_count %>
|
|
409
|
+
<%= cart.component :summary %>
|
|
410
|
+
<% end %>
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
The signed token proves integrity, not authorization. `ActorChannel#subscribed` verifies the token, resolves the registered actor type, invokes `authorize_subscription`, and only then streams.
|
|
414
|
+
|
|
415
|
+
Broadcast replacements happen after the actor transaction commits because only a committed broadcast outbox row can be delivered. Multiple values share the same Action Cable connection and one actor subscription.
|
|
416
|
+
|
|
417
|
+
Each channel subscription transmits current observable replacements before streaming future broadcasts, including after reconnect. Missing a broadcast therefore creates temporary staleness, not permanent divergence.
|
|
418
|
+
|
|
419
|
+
## Authorization
|
|
420
|
+
|
|
421
|
+
Configuration provides five explicit policies:
|
|
422
|
+
|
|
423
|
+
- `authorize_message`
|
|
424
|
+
- `authorize_query`
|
|
425
|
+
- `authorize_destroy`
|
|
426
|
+
- `authorize_subscription`
|
|
427
|
+
- `authorize_administration`
|
|
428
|
+
|
|
429
|
+
Each receives a request context, actor type, actor ID, and relevant operation
|
|
430
|
+
details. A host can set request context using an isolated execution-state
|
|
431
|
+
carrier. Internal runtime deliveries carry a system context that is separately
|
|
432
|
+
recognizable.
|
|
433
|
+
|
|
434
|
+
No controller, channel, or administrative command treats an actor ID, message ID, request ID, or signed stream name as authorization.
|
|
435
|
+
|
|
436
|
+
Actor IDs are bounded UTF-8 strings and never become constant names, SQL identifiers, file paths, or raw stream names.
|
|
437
|
+
|
|
438
|
+
## Serialization
|
|
439
|
+
|
|
440
|
+
The default JSON serializer accepts:
|
|
441
|
+
|
|
442
|
+
- `nil`
|
|
443
|
+
- booleans
|
|
444
|
+
- finite numbers
|
|
445
|
+
- UTF-8 strings
|
|
446
|
+
- arrays
|
|
447
|
+
- objects with string or symbol keys that normalize uniquely to strings
|
|
448
|
+
|
|
449
|
+
It rejects arbitrary Ruby objects, non-finite floats, duplicate keys after normalization, excessive nesting, and values over configured byte limits.
|
|
450
|
+
|
|
451
|
+
Separate size limits exist for:
|
|
452
|
+
|
|
453
|
+
- Actor ID
|
|
454
|
+
- Message arguments
|
|
455
|
+
- Message results
|
|
456
|
+
- Actor state
|
|
457
|
+
- Effect arguments and results
|
|
458
|
+
- Reminder arguments
|
|
459
|
+
- Error backtraces
|
|
460
|
+
|
|
461
|
+
The initial release has one strict JSON serializer and no arbitrary object coercion. Versioned serializer extension points are roadmap work. Unsafe `Marshal` is not used.
|
|
462
|
+
|
|
463
|
+
## State migration and rolling deployment
|
|
464
|
+
|
|
465
|
+
An actor defines:
|
|
466
|
+
|
|
467
|
+
```ruby
|
|
468
|
+
state_version 2
|
|
469
|
+
|
|
470
|
+
migrate_state from: 1, to: 2 do |state|
|
|
471
|
+
state["currency"] ||= "USD"
|
|
472
|
+
state
|
|
473
|
+
end
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
Activation applies each step in order. Missing steps, cycles, non-JSON output, or stored versions newer than code fail activation.
|
|
477
|
+
|
|
478
|
+
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`.
|
|
479
|
+
|
|
480
|
+
Rolling deployment rules:
|
|
481
|
+
|
|
482
|
+
1. Additive code that reads old and new state can roll normally.
|
|
483
|
+
2. A new worker may migrate and persist state only if old workers can still read that representation.
|
|
484
|
+
3. If old code cannot read new state, drain old workers before enabling the migration.
|
|
485
|
+
4. A worker seeing a newer state version fails fast rather than guessing.
|
|
486
|
+
5. Actor message and observable names removed in a release must remain accepted until old queued messages and broadcast rows are drained or migrated.
|
|
487
|
+
6. Additive, backward-readable representation changes need no version bump.
|
|
488
|
+
7. Destructive changes use expand/contract releases.
|
|
489
|
+
8. Published actor migration steps are never squashed because dormant actors can retain old state indefinitely.
|
|
490
|
+
|
|
491
|
+
Process metadata exposes runtime and application version so operators can find mixed fleets.
|
|
492
|
+
|
|
493
|
+
## Backpressure
|
|
494
|
+
|
|
495
|
+
### Maximum mailbox length
|
|
496
|
+
|
|
497
|
+
Enqueue counts unfinished rows under the locked actor instance and rejects with `MailboxFull` above the configured limit. System outcome messages may have a reserved allowance to avoid deadlocking workflows.
|
|
498
|
+
|
|
499
|
+
### Per-actor rate limits
|
|
500
|
+
|
|
501
|
+
The initial implementation supplies the mailbox cap. Distributed token buckets or time-window counters are a hardening milestone.
|
|
502
|
+
|
|
503
|
+
### Global enqueue limits
|
|
504
|
+
|
|
505
|
+
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.
|
|
506
|
+
|
|
507
|
+
### Payload size
|
|
508
|
+
|
|
509
|
+
Serialization validates byte size before enqueue and before commit. The configured database remains the final storage boundary.
|
|
510
|
+
|
|
511
|
+
### Slow messages
|
|
512
|
+
|
|
513
|
+
Instrumentation records execution duration. Lease renewal prevents ordinary long turns from being stolen. The pass-duration budget is checked between messages and does not preempt a running handler. Ruby code cannot be safely preempted in-process; hard termination requires process isolation.
|
|
514
|
+
|
|
515
|
+
### Hot actors and fairness
|
|
516
|
+
|
|
517
|
+
Bounded messages and activation duration force a yield. Candidate order prefers the actor whose executable membership has waited longest, and yielded due memberships move behind already-waiting work. Per-actor sequentiality means one hot actor has a natural single-actor throughput ceiling.
|
|
518
|
+
|
|
519
|
+
### Poison messages
|
|
520
|
+
|
|
521
|
+
Backoff and a retry limit prevent tight loops. The poison message blocks its actor until dead-lettered, then later messages continue.
|
|
522
|
+
|
|
523
|
+
### Handler redelivery
|
|
524
|
+
|
|
525
|
+
Sequential processing does not mean single execution. A handler can run, lose its lease before commit, and run again. Logical transitions must guard on durable state:
|
|
526
|
+
|
|
527
|
+
```ruby
|
|
528
|
+
def launch
|
|
529
|
+
return if status == "launched"
|
|
530
|
+
|
|
531
|
+
self.status = "launched"
|
|
532
|
+
emit :launch_vehicle, launch_id: actor_id
|
|
533
|
+
end
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
The guard makes the state transition repeatable. The outbox commits atomically with state, and the external consumer still deduplicates by the stable effect ID.
|
|
537
|
+
|
|
538
|
+
## Deactivation
|
|
539
|
+
|
|
540
|
+
An activation becomes idle when it has no due earliest message and no turn in flight. It can remain cached for the configured idle timeout while its lease is renewed. It is released when the idle timeout is reached or the pass budget forces a fairness yield. Separate cache-pressure eviction is not implemented.
|
|
541
|
+
|
|
542
|
+
Graceful release conditionally clears owner and expiration only if the generation still matches. Expired leases need no explicit cleanup before another worker claims them.
|
|
543
|
+
|
|
544
|
+
`on_deactivate` is best effort and nondurable. It may not run on crash and cannot be the source of a correctness requirement.
|
|
545
|
+
|
|
546
|
+
## Advisory locks
|
|
547
|
+
|
|
548
|
+
PostgreSQL session-level advisory locks and MySQL named locks are not used for activations because they:
|
|
549
|
+
|
|
550
|
+
- Pin a database connection
|
|
551
|
+
- Couple actor lifetime to one session
|
|
552
|
+
- Do not persist a fencing generation
|
|
553
|
+
- Consume backend lock-manager resources
|
|
554
|
+
|
|
555
|
+
PostgreSQL transaction-level advisory locks may be used for optional singleton maintenance tasks, but portable row or write transactions and unique constraints are preferred when a durable record already exists.
|
|
556
|
+
|
|
557
|
+
## Transaction map
|
|
558
|
+
|
|
559
|
+
| Operation | One transaction |
|
|
560
|
+
| --- | --- |
|
|
561
|
+
| Create actor and allocate message sequence | Instance insert/lock, sequence increment, durable message and ready-membership inserts |
|
|
562
|
+
| Claim activation | Backend claim transaction, generation increment, owner and expiry |
|
|
563
|
+
| Claim next message | Move ready membership to claimed membership conditioned on lease |
|
|
564
|
+
| Successful message commit | Fenced state, durable message result, claimed-membership deletion, effects, reminders, actor outbox, broadcasts |
|
|
565
|
+
| Failed message attempt | Conditional error, claimed deletion, ready reinsertion or dead letter |
|
|
566
|
+
| Renew or release lease | Conditional instance update |
|
|
567
|
+
| Destroy actor | Instance identity lock and cascading delete of state, mailbox, reminders, and outboxes |
|
|
568
|
+
| Deliver reminder occurrence | Source instance lock, mailbox enqueue, and reminder advance, with a stable occurrence idempotency key |
|
|
569
|
+
| Claim outbox batch | Backend claim transaction and delivery ownership |
|
|
570
|
+
| Record outbox outcome | Success or retry/dead status |
|
|
571
|
+
|
|
572
|
+
Actor code and external network effects are never executed inside these transactions.
|
|
573
|
+
|
|
574
|
+
## Database-dependent implementations
|
|
575
|
+
|
|
576
|
+
The semantic guarantees are common, but their coordination implementations differ:
|
|
577
|
+
|
|
578
|
+
| Capability | PostgreSQL | MySQL InnoDB | SQLite |
|
|
579
|
+
| --- | --- | --- | --- |
|
|
580
|
+
| Concurrent claim | `FOR UPDATE SKIP LOCKED` | `FOR UPDATE SKIP LOCKED` | Serialized `BEGIN IMMEDIATE` |
|
|
581
|
+
| JSON state | JSONB | JSON | Rails JSON type |
|
|
582
|
+
| Executable-work indexes | Ready/claimed membership tables | Ready/claimed membership tables | Ready/claimed membership tables |
|
|
583
|
+
| Write isolation | Row locks and MVCC | InnoDB row/next-key locks and MVCC | One writer, serializable writes |
|
|
584
|
+
| Contention retry | Database/Active Record behavior; explicit classification is roadmap | Database/Active Record behavior; explicit classification is roadmap | Busy timeout; explicit busy classification is roadmap |
|
|
585
|
+
| Lease clock | Database current time | Database current time | Database current time |
|
|
586
|
+
|
|
587
|
+
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.
|
|
588
|
+
|
|
589
|
+
## Answers to required correctness questions
|
|
590
|
+
|
|
591
|
+
1. **How is a per-actor sequence allocated safely?** The instance row is created uniquely, locked in the enqueue transaction, incremented, and the message inserted under a unique actor/sequence index.
|
|
592
|
+
2. **How is one valid activation guaranteed?** Claim atomically changes owner and increments generation on one locked instance row. Only the matching unexpired owner/generation can commit.
|
|
593
|
+
3. **How are stale writes rejected?** Every commit verifies owner, generation, and database-time expiration.
|
|
594
|
+
4. **What if a worker dies during execution?** No actor transaction remains open. After lease expiry, a new generation retries the uncommitted message.
|
|
595
|
+
5. **What if it dies after commit but before acknowledgement?** Completion and state are already durable, so the new activation skips that message.
|
|
596
|
+
6. **How are external effects retry-safe?** Effects are inserted atomically into an outbox and use a stable effect ID for handler idempotency.
|
|
597
|
+
7. **How are messages ordered?** Explicit per-actor sequence, earliest unfinished first.
|
|
598
|
+
8. **Can failed messages block later messages?** Yes, while retryable. Dead-lettering unblocks later messages.
|
|
599
|
+
9. **How are poison messages handled?** Bounded retries, backoff, dead letter, operator inspection and tail retry.
|
|
600
|
+
10. **How are hot actors prevented from monopolizing workers?** Message and duration budgets plus earliest-waiting membership scheduling and hot-actor yield.
|
|
601
|
+
11. **How are actors deactivated?** Idle cache timeout or eviction, best-effort hook, conditional lease release.
|
|
602
|
+
12. **How are leases renewed?** Conditional database update by instance, owner, generation, and unexpired lease.
|
|
603
|
+
13. **How does graceful shutdown work?** Stop claims, finish current turn within timeout, release cached leases, stop heartbeat, mark process stopped.
|
|
604
|
+
14. **How does ask work across processes?** A wake-up adapter prompts a durable result query; periodic polling remains the fallback. Polling-only use is scoped to non-latency-sensitive callers.
|
|
605
|
+
15. **What happens after caller timeout?** The durable message continues and its eventual result remains on the message row.
|
|
606
|
+
16. **How are results cleaned up?** The schema has cleanup indexes, but bounded retention tooling is not implemented yet.
|
|
607
|
+
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.
|
|
608
|
+
18. **How are completed messages pruned?** Cleanup indexes support future bounded pruning; the initial release does not automatically prune them.
|
|
609
|
+
19. **How are state migrations performed?** Explicit one-step actor migrations on activation, persisted only with a successful fenced commit.
|
|
610
|
+
20. **What happens during rolling deploys?** Newer state can make old workers incompatible; deploys must preserve backward readability or drain old workers.
|
|
611
|
+
21. **How are subscriptions authorized?** Verify signed identity, resolve registered type, invoke host authorization, then stream.
|
|
612
|
+
22. **How are lost broadcasts recovered?** Current-state refresh after reconnect; durable outbox retries server delivery.
|
|
613
|
+
23. **How are actor-to-actor cycles handled?** Synchronous actor waits are rejected; asynchronous request/result messages avoid call-stack cycles.
|
|
614
|
+
24. **Which operations are transactional?** The transaction map above lists every atomic boundary; actor code and I/O are outside.
|
|
615
|
+
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/benchmarks.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Local benchmarks
|
|
2
|
+
|
|
3
|
+
These numbers are development measurements, not universal capacity guarantees.
|
|
4
|
+
They include the runtime's Active Record and database query overhead and will
|
|
5
|
+
vary with hardware, schema size, connection pools, durability settings, and
|
|
6
|
+
contention.
|
|
7
|
+
|
|
8
|
+
Measured 2026-08-05 on an Apple M5 with 24 GB RAM, Ruby 4.0.5, Rails 8.1.3.1,
|
|
9
|
+
and SQLite 3.51.0. Each throughput scenario used 200 operations; the concurrent
|
|
10
|
+
scenario used four worker threads.
|
|
11
|
+
|
|
12
|
+
| Scenario | Result |
|
|
13
|
+
| --- | ---: |
|
|
14
|
+
| Enqueue, one actor | 522.4 messages/s |
|
|
15
|
+
| Claim, 200 actors | 1,009.3 claims/s |
|
|
16
|
+
| Process, 40 actors round-robin | 548.7 messages/s |
|
|
17
|
+
| Process, 200 cold actors | 119.5 messages/s |
|
|
18
|
+
| Process, one hot actor | 729.1 messages/s |
|
|
19
|
+
| Process, four workers | 568.2 messages/s |
|
|
20
|
+
| Ask latency | p50 9.2 ms, p95 15.6 ms, p99 53.7 ms |
|
|
21
|
+
| Activation reuse | 98.0%, four activations for 200 messages |
|
|
22
|
+
| Queries for one message turn | 28 |
|
|
23
|
+
|
|
24
|
+
The scripts and invocation examples are in the
|
|
25
|
+
[development guide](development.md#benchmarks). PostgreSQL and MySQL should be
|
|
26
|
+
benchmarked independently before selecting production capacity.
|