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.
Files changed (178) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +17 -0
  3. data/MIT-LICENSE +19 -0
  4. data/README.md +744 -0
  5. data/Rakefile +40 -0
  6. data/app/controllers/solid_objects/application_controller.rb +23 -0
  7. data/app/controllers/solid_objects/dead_letters_controller.rb +23 -0
  8. data/app/controllers/solid_objects/instances_controller.rb +29 -0
  9. data/app/helpers/solid_objects/actor_helper.rb +25 -0
  10. data/app/models/solid_objects/broadcast.rb +10 -0
  11. data/app/models/solid_objects/claimed_message.rb +14 -0
  12. data/app/models/solid_objects/dead_letter.rb +13 -0
  13. data/app/models/solid_objects/effect.rb +10 -0
  14. data/app/models/solid_objects/instance.rb +93 -0
  15. data/app/models/solid_objects/message.rb +53 -0
  16. data/app/models/solid_objects/process.rb +13 -0
  17. data/app/models/solid_objects/ready_message.rb +10 -0
  18. data/app/models/solid_objects/record.rb +17 -0
  19. data/app/models/solid_objects/reminder.rb +9 -0
  20. data/app/views/solid_objects/dead_letters/index.html.erb +26 -0
  21. data/app/views/solid_objects/instances/index.html.erb +24 -0
  22. data/app/views/solid_objects/instances/show.html.erb +35 -0
  23. data/benchmark/activation_cache.rb +5 -0
  24. data/benchmark/ask_latency.rb +5 -0
  25. data/benchmark/claim.rb +5 -0
  26. data/benchmark/cold_actors.rb +5 -0
  27. data/benchmark/concurrent_actors.rb +5 -0
  28. data/benchmark/enqueue.rb +5 -0
  29. data/benchmark/hot_actor.rb +5 -0
  30. data/benchmark/processing.rb +5 -0
  31. data/benchmark/query_count.rb +5 -0
  32. data/benchmark/support.rb +271 -0
  33. data/config/routes.rb +8 -0
  34. data/db/migrate/20260805000000_create_solid_objects_tables.rb +319 -0
  35. data/docs/adr/0001-postgresql-backend.md +21 -0
  36. data/docs/adr/0002-jsonb-actor-state.md +21 -0
  37. data/docs/adr/0003-mailbox-ordering.md +30 -0
  38. data/docs/adr/0004-activation-leasing.md +21 -0
  39. data/docs/adr/0005-fencing-tokens.md +25 -0
  40. data/docs/adr/0006-at-least-once-delivery.md +24 -0
  41. data/docs/adr/0007-transactional-outbox.md +21 -0
  42. data/docs/adr/0008-actor-communication.md +21 -0
  43. data/docs/adr/0009-realtime-updates.md +21 -0
  44. data/docs/adr/0010-state-versioning.md +29 -0
  45. data/docs/adr/0011-wake-up-strategy.md +34 -0
  46. data/docs/adr/0012-not-active-jobs.md +21 -0
  47. data/docs/adr/0013-database-adapters.md +48 -0
  48. data/docs/architecture.md +615 -0
  49. data/docs/benchmarks.md +26 -0
  50. data/docs/correctness.md +124 -0
  51. data/docs/database-schema.md +111 -0
  52. data/docs/development.md +87 -0
  53. data/docs/implementation-plan.md +518 -0
  54. data/docs/operations.md +123 -0
  55. data/docs/realtime.md +51 -0
  56. data/docs/research/solid_queue.md +545 -0
  57. data/docs/roadmap.md +53 -0
  58. data/docs/security.md +61 -0
  59. data/docs/state-migrations.md +46 -0
  60. data/examples/application/README.md +16 -0
  61. data/examples/application/app/actors/chat_room_actor.rb +34 -0
  62. data/examples/application/app/actors/shopping_cart_actor.rb +79 -0
  63. data/examples/application/app/controllers/cart_controller.rb +54 -0
  64. data/examples/application/app/controllers/chat_rooms_controller.rb +44 -0
  65. data/examples/application/app/views/actors/chat_room_actor/_messages.html.erb +8 -0
  66. data/examples/application/app/views/actors/shopping_cart_actor/_summary.html.erb +10 -0
  67. data/examples/application/app/views/cart/show.html.erb +13 -0
  68. data/examples/application/app/views/chat_rooms/show.html.erb +8 -0
  69. data/examples/application/config/initializers/solid_objects.rb +23 -0
  70. data/examples/application/config/routes.rb +20 -0
  71. data/exe/solid_objects +9 -0
  72. data/lib/generators/solid_objects/install_generator.rb +21 -0
  73. data/lib/generators/solid_objects/templates/solid_objects.rb +13 -0
  74. data/lib/solid_objects/action_cable_broadcast_adapter.rb +19 -0
  75. data/lib/solid_objects/activation.rb +183 -0
  76. data/lib/solid_objects/activation_manager.rb +102 -0
  77. data/lib/solid_objects/actor.rb +271 -0
  78. data/lib/solid_objects/actor_channel.rb +29 -0
  79. data/lib/solid_objects/actor_definition.rb +212 -0
  80. data/lib/solid_objects/actor_registry.rb +65 -0
  81. data/lib/solid_objects/actor_snapshot.rb +42 -0
  82. data/lib/solid_objects/actor_view.rb +117 -0
  83. data/lib/solid_objects/broadcast_executor.rb +162 -0
  84. data/lib/solid_objects/cli.rb +118 -0
  85. data/lib/solid_objects/client.rb +153 -0
  86. data/lib/solid_objects/configuration.rb +168 -0
  87. data/lib/solid_objects/context.rb +41 -0
  88. data/lib/solid_objects/database_adapter.rb +82 -0
  89. data/lib/solid_objects/database_adapters/mysql.rb +22 -0
  90. data/lib/solid_objects/database_adapters/postgresql.rb +17 -0
  91. data/lib/solid_objects/database_adapters/sqlite.rb +12 -0
  92. data/lib/solid_objects/dead_letter_manager.rb +47 -0
  93. data/lib/solid_objects/dom_identity.rb +38 -0
  94. data/lib/solid_objects/effect_executor.rb +235 -0
  95. data/lib/solid_objects/effect_registry.rb +34 -0
  96. data/lib/solid_objects/engine.rb +33 -0
  97. data/lib/solid_objects/errors.rb +65 -0
  98. data/lib/solid_objects/executor.rb +290 -0
  99. data/lib/solid_objects/instrumentation.rb +10 -0
  100. data/lib/solid_objects/lease.rb +172 -0
  101. data/lib/solid_objects/lease_renewer.rb +70 -0
  102. data/lib/solid_objects/log_subscriber.rb +29 -0
  103. data/lib/solid_objects/mailbox.rb +178 -0
  104. data/lib/solid_objects/message_reference.rb +52 -0
  105. data/lib/solid_objects/process_registry.rb +143 -0
  106. data/lib/solid_objects/reference.rb +96 -0
  107. data/lib/solid_objects/reminder_scheduler.rb +168 -0
  108. data/lib/solid_objects/serialization.rb +99 -0
  109. data/lib/solid_objects/state.rb +111 -0
  110. data/lib/solid_objects/stream_name.rb +29 -0
  111. data/lib/solid_objects/stream_token.rb +59 -0
  112. data/lib/solid_objects/supervisor.rb +87 -0
  113. data/lib/solid_objects/turbo_stream_renderer.rb +35 -0
  114. data/lib/solid_objects/version.rb +5 -0
  115. data/lib/solid_objects/wake_up.rb +28 -0
  116. data/lib/solid_objects/worker.rb +139 -0
  117. data/lib/solid_objects.rb +118 -0
  118. data/sig/generated/controllers/solid_objects/application_controller.rbs +10 -0
  119. data/sig/generated/controllers/solid_objects/dead_letters_controller.rbs +11 -0
  120. data/sig/generated/controllers/solid_objects/instances_controller.rbs +11 -0
  121. data/sig/generated/helpers/solid_objects/actor_helper.rbs +8 -0
  122. data/sig/generated/lib/generators/solid_objects/install_generator.rbs +13 -0
  123. data/sig/generated/lib/solid_objects/action_cable_broadcast_adapter.rbs +8 -0
  124. data/sig/generated/lib/solid_objects/activation.rbs +65 -0
  125. data/sig/generated/lib/solid_objects/activation_manager.rbs +36 -0
  126. data/sig/generated/lib/solid_objects/actor.rbs +183 -0
  127. data/sig/generated/lib/solid_objects/actor_channel.rbs +8 -0
  128. data/sig/generated/lib/solid_objects/actor_definition.rbs +117 -0
  129. data/sig/generated/lib/solid_objects/actor_registry.rbs +36 -0
  130. data/sig/generated/lib/solid_objects/actor_snapshot.rbs +28 -0
  131. data/sig/generated/lib/solid_objects/actor_view.rbs +56 -0
  132. data/sig/generated/lib/solid_objects/broadcast_executor.rbs +55 -0
  133. data/sig/generated/lib/solid_objects/cli.rbs +31 -0
  134. data/sig/generated/lib/solid_objects/client.rbs +35 -0
  135. data/sig/generated/lib/solid_objects/configuration.rbs +147 -0
  136. data/sig/generated/lib/solid_objects/context.rbs +56 -0
  137. data/sig/generated/lib/solid_objects/database_adapter.rbs +42 -0
  138. data/sig/generated/lib/solid_objects/database_adapters/mysql.rbs +16 -0
  139. data/sig/generated/lib/solid_objects/database_adapters/postgresql.rbs +13 -0
  140. data/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs +10 -0
  141. data/sig/generated/lib/solid_objects/dead_letter_manager.rbs +16 -0
  142. data/sig/generated/lib/solid_objects/dom_identity.rbs +20 -0
  143. data/sig/generated/lib/solid_objects/effect_executor.rbs +82 -0
  144. data/sig/generated/lib/solid_objects/effect_registry.rbs +24 -0
  145. data/sig/generated/lib/solid_objects/engine.rbs +7 -0
  146. data/sig/generated/lib/solid_objects/errors.rbs +64 -0
  147. data/sig/generated/lib/solid_objects/executor.rbs +60 -0
  148. data/sig/generated/lib/solid_objects/instrumentation.rbs +8 -0
  149. data/sig/generated/lib/solid_objects/lease.rbs +57 -0
  150. data/sig/generated/lib/solid_objects/lease_renewer.rbs +42 -0
  151. data/sig/generated/lib/solid_objects/log_subscriber.rbs +11 -0
  152. data/sig/generated/lib/solid_objects/mailbox.rbs +46 -0
  153. data/sig/generated/lib/solid_objects/message_reference.rbs +37 -0
  154. data/sig/generated/lib/solid_objects/process_registry.rbs +46 -0
  155. data/sig/generated/lib/solid_objects/reference.rbs +45 -0
  156. data/sig/generated/lib/solid_objects/reminder_scheduler.rbs +55 -0
  157. data/sig/generated/lib/solid_objects/serialization.rbs +31 -0
  158. data/sig/generated/lib/solid_objects/state.rbs +72 -0
  159. data/sig/generated/lib/solid_objects/stream_name.rbs +11 -0
  160. data/sig/generated/lib/solid_objects/stream_token.rbs +19 -0
  161. data/sig/generated/lib/solid_objects/supervisor.rbs +38 -0
  162. data/sig/generated/lib/solid_objects/turbo_stream_renderer.rbs +14 -0
  163. data/sig/generated/lib/solid_objects/version.rbs +5 -0
  164. data/sig/generated/lib/solid_objects/wake_up.rbs +24 -0
  165. data/sig/generated/lib/solid_objects/worker.rbs +56 -0
  166. data/sig/generated/lib/solid_objects.rbs +41 -0
  167. data/sig/generated/models/solid_objects/broadcast.rbs +6 -0
  168. data/sig/generated/models/solid_objects/claimed_message.rbs +6 -0
  169. data/sig/generated/models/solid_objects/dead_letter.rbs +6 -0
  170. data/sig/generated/models/solid_objects/effect.rbs +6 -0
  171. data/sig/generated/models/solid_objects/instance.rbs +25 -0
  172. data/sig/generated/models/solid_objects/message.rbs +22 -0
  173. data/sig/generated/models/solid_objects/process.rbs +6 -0
  174. data/sig/generated/models/solid_objects/ready_message.rbs +6 -0
  175. data/sig/generated/models/solid_objects/record.rbs +8 -0
  176. data/sig/generated/models/solid_objects/reminder.rbs +6 -0
  177. data/sig/support/framework.rbs +37 -0
  178. 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.
@@ -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.