solid_objects 0.14.1 → 0.14.2

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.
data/README.md CHANGED
@@ -5,79 +5,92 @@
5
5
  **Self-hosted, distributed Durable Objects in Rails without a daemon using your existing SQL database.**
6
6
 
7
7
  Solid Objects ports the Durable Objects programming model to ordinary Rails
8
- applications: addressable objects, durable state, serialized turns, alarms,
9
- and live clients. It runs on the MySQL, PostgreSQL, or SQLite database that
10
- the application already has, in the database-backed operating model of the
11
- Solid family. No Redis, Cloudflare account, or separate actor service is
12
- required.
8
+ applications: addressable objects, durable state, serialized turns, alarms, and
9
+ live clients. It runs on the MySQL, PostgreSQL, or SQLite database the
10
+ application already has. No Redis, Cloudflare account, or separate actor
11
+ service is required.
12
+
13
+ > **Not a replacement for SQL transactions:** when one row update inside one
14
+ > transaction answers the question, use that and install nothing. Solid Objects
15
+ > earns its cost when the critical section outlives the transaction: a hold that
16
+ > expires in ten minutes, work that must survive a restart, or a fan-in that
17
+ > spans many jobs. See [Why not just use transactions?](#why-not-just-use-transactions).
18
+
19
+ A ticket sale for one event, with 100 seats and a hold that expires:
13
20
 
14
21
  ```ruby
15
- class Counter < SolidObjects::Actor
16
- attribute :value, default: 0
22
+ class TicketSale < SolidObjects::Actor
23
+ attribute :remaining, default: 100
24
+ attribute :holds, default: -> { {} }
25
+ observable :remaining, broadcast: :value
26
+ observable :holds
27
+
28
+ def reserve(buyer:)
29
+ return false if remaining.zero? || holds.key?(buyer)
30
+
31
+ self.remaining -= 1
32
+ self.holds = holds.merge(buyer => Time.now.utc.to_i)
33
+ schedule(at: 10.minutes.from_now, key: buyer).expire(buyer:)
34
+ true
35
+ end
17
36
 
18
- def increment(amount: 1)
19
- self.value += amount
37
+ def expire(buyer:)
38
+ return unless holds.key?(buyer)
39
+
40
+ self.holds = holds.except(buyer)
41
+ self.remaining += 1
20
42
  end
21
43
  end
22
44
 
23
45
  # Synchronous caller-assisted RPC. No worker fleet is required.
24
- counter = Counter.ref("global")
25
- count = counter.increment(amount: 5)
26
- current_count = counter.value
27
- current_snapshot = counter.snapshot.value
28
-
29
- # Durable fire-and-forget delivery. A worker processes it later.
30
- message = counter.async.increment(amount: 5)
46
+ sale = TicketSale.ref("event-42")
47
+ sale.reserve(buyer: current_user.id)
31
48
  ```
32
49
 
33
- `Counter / global` is a logical identity. Like a Durable Object named with
50
+ That example wants three things from the same number. It must never go below
51
+ zero. It must give the seat back if the buyer does not pay within ten minutes.
52
+ It must show the current count to everyone watching the page.
53
+
54
+ The first is one line of SQL. The second is an `expires_at` column plus a cron
55
+ job that sweeps it. The third is a broadcast on every code path that changes
56
+ the number. The combination is what costs, not any one of them. Here the guard,
57
+ the ten-minute alarm, and the live count are one class, and they commit
58
+ together.
59
+
60
+ `TicketSale / event-42` is a logical identity. Like a Durable Object named with
34
61
  `idFromName`, it can be addressed from anywhere without first creating or
35
62
  locating a Ruby object. Solid Objects activates it when work arrives, commits
36
63
  its ordered turns one at a time, persists its state, and deactivates it when
37
- idle. Different identities can run concurrently.
64
+ idle. Different identities run concurrently, so two events never wait on each
65
+ other.
38
66
 
39
- The invocation model is the first adoption decision:
67
+ The invocation model is the first adoption decision. A direct call or `sync`
68
+ needs no worker fleet, because the Rails caller helps execute the actor through
69
+ the same mailbox, lease, and fencing path a worker would use. `async` only
70
+ enqueues and returns a `MessageReference`, so a runtime process handles it
71
+ later.
40
72
 
41
73
  | Call | Returns | Worker fleet required? |
42
74
  | --- | --- | --- |
43
- | `counter.increment(amount: 5)` | Committed handler result | No |
44
- | `counter.sync(timeout: 5.seconds).increment(amount: 5)` | Committed handler result | No |
45
- | `counter.value` | Ordered, committed query result | No |
46
- | `counter.snapshot.value` | Current committed state without a mailbox message | No |
47
- | `counter.async.increment(amount: 5)` | `MessageReference` immediately | Yes |
48
-
49
- Direct methods and `sync` durably enqueue the call, then the Rails caller helps
50
- execute the actor through the same mailbox, lease, and fencing path as a
51
- worker. `async` only enqueues; a runtime process handles it later.
52
-
53
- Synchronous calls fail before enqueue when the Solid Objects database
54
- connection is already inside a transaction. Actor handlers may read application
55
- records, but direct Active Record writes are rejected so they cannot escape a
56
- later actor failure. Use a same-database
75
+ | `sale.reserve(buyer: id)` | Committed handler result | No |
76
+ | `sale.remaining` | Ordered, committed query result | No |
77
+ | `sale.snapshot.remaining` | Committed state without a mailbox message | No |
78
+ | `sale.async.reserve(buyer: id)` | `MessageReference` immediately | Yes |
79
+
80
+ Actor handlers may read application records, but direct Active Record writes
81
+ are rejected so they cannot escape a later actor failure. Use a same-database
57
82
  [`commit_action`](#application-database-writes) for atomic database changes and
58
83
  [`emit`](#effects) for external I/O.
59
84
 
60
- Before adopting a latency-sensitive or high-volume surface, read
61
- [Is Solid Objects a good fit?](docs/fit.md) and the
62
- [measured performance and row-growth costs](docs/benchmarks.md).
63
-
64
- This is a port of the programming model, not Cloudflare's edge runtime or
65
- platform. Read the conceptual overview at [solidobjects.dev](https://solidobjects.dev/)
66
- and the exact Rails guarantees in [Correctness and delivery semantics](docs/correctness.md).
67
-
68
- Solid Objects is an early release. Its correctness core is implemented and
69
- tested, but the project does not yet claim production readiness. See
70
- [Status](#status) and the [roadmap](docs/roadmap.md).
71
-
72
- ## Table of contents
85
+ ## Contents
73
86
 
87
+ - [Why not just use transactions?](#why-not-just-use-transactions)
88
+ - [Is it worth installing here?](#is-it-worth-installing-here)
74
89
  - [Cloudflare Durable Objects for Rails](#cloudflare-durable-objects-for-rails)
75
90
  - [Reactive ERB](#reactive-erb)
76
91
  - [Installation](#installation)
77
- - [Upgrading](#upgrading)
78
92
  - [Worker requirements](#worker-requirements)
79
93
  - [Defining an actor](#defining-an-actor)
80
- - [Actor identity](#actor-identity)
81
94
  - [Invoking an object](#invoking-an-object)
82
95
  - [Application database writes](#application-database-writes)
83
96
  - [Effects](#effects)
@@ -89,12 +102,67 @@ tested, but the project does not yet claim production readiness. See
89
102
  - [Dashboard](#dashboard)
90
103
  - [Database support](#database-support)
91
104
  - [Guarantees](#guarantees)
92
- - [When to use it](#when-to-use-it)
93
105
  - [Comparisons](#comparisons)
94
- - [Development](#development)
106
+ - [Development and contributing](#development-and-contributing)
95
107
  - [Status](#status)
96
108
  - [License](#license)
97
109
 
110
+ ## Why not just use transactions?
111
+
112
+ Often you should. If the whole job is read a row, decide, write it back, and
113
+ answer the user, then `with_lock` does that and you need nothing else
114
+ installed. Reach for it first.
115
+
116
+ The argument for an actor is scope, not discipline. A lock is scoped to one
117
+ transaction, on one connection, in one process. The ticket sale above leaves
118
+ that scope on one line: the hold expires in ten minutes, and no transaction
119
+ stays open for ten minutes.
120
+
121
+ Any column named `expires_at`, `scheduled_at`, or `next_run_at` is evidence
122
+ that the critical section already outlived the lock that was supposed to cover
123
+ it. What follows such a column is a sweeper that looks for due rows, and then a
124
+ race between that sweeper and the next writer of the same row. The column, the
125
+ sweeper, and the race are what a Solid Objects actor replaces.
126
+
127
+ Three things a lock cannot reach:
128
+
129
+ - work that fires at a future moment, when no transaction of yours is open;
130
+ - work that must survive a process restart, which rules out an in-process
131
+ timer; and
132
+ - a fan-in whose critical section spans many jobs over minutes, such as an
133
+ import that counts its own chunks as each one finishes.
134
+
135
+ If it all happens inside one request, use a lock.
136
+
137
+ ## Is it worth installing here?
138
+
139
+ Worth it when several requests, jobs, or processes act on the same cart, chat
140
+ room, device twin, game room, or long-lived workflow, and each next action
141
+ needs the last committed state. Worth it when that same thing also owns work
142
+ that fires later, or a number a live page must show.
143
+
144
+ Not worth it for a plain counter, a single-row update inside one transaction, a
145
+ stateless job, bulk ingestion or a data-parallel pipeline, CPU-heavy work, a
146
+ large JSON document that belongs in normalized rows, high-QPS request reads, or
147
+ a global rate-limit counter that every request touches. One hot identity is
148
+ serialized on purpose, so making everything one identity makes a queue.
149
+
150
+ High-QPS reads and hot identities are where this runtime stops being the right
151
+ tool on its own. [Solid Objects Pro](https://solidobjects.pro/) is a commercial
152
+ performance layer for this gem that adds grouped commits, which coalesce
153
+ concurrent writes into fewer database commits; optional ephemeral operations,
154
+ which take loss-tolerant calls out of the durable journal; and materialized
155
+ projections, which build read models after commit so reads stop competing with
156
+ mailbox work.
157
+
158
+ Before moving an existing surface, read the
159
+ [fit and anti-pattern guide](docs/fit.md), the
160
+ [measured costs](docs/benchmarks.md), and the
161
+ [migration cookbook](docs/migrating-existing-state.md). This ports the
162
+ programming model, not Cloudflare's edge runtime; the exact Rails guarantees
163
+ are in [correctness](docs/correctness.md), and this is an early release with no
164
+ production-readiness claim.
165
+
98
166
  ## Cloudflare Durable Objects for Rails
99
167
 
100
168
  Cloudflare Durable Objects combine a name, durable storage, serialized
@@ -113,238 +181,60 @@ maps those ideas into Rails:
113
181
  | Storage deletion | Authorized `reference.destroy` |
114
182
  | Cloudflare Workers platform | Your Rails processes and SQL database |
115
183
 
116
- Rails already has tools for jobs, records, and realtime transport.
117
- None of those primitives alone provides this complete stateful-object shape.
118
- Solid Objects adds five capabilities:
119
-
120
- ### Ordered delivery per identity
121
-
122
- Every enqueue locks the actor instance and allocates an explicit, monotonically
123
- increasing sequence number. An activation always takes the lowest live sequence
124
- for that actor. A retryable failure keeps later messages blocked until the
125
- failed message succeeds or reaches its dead letter.
126
-
127
- This is stronger than a concurrency limit. Solid Queue's
184
+ Every enqueue allocates a monotonically increasing sequence number, and an
185
+ activation always takes the lowest live one, which is stronger than a
186
+ concurrency limit: Solid Queue's
128
187
  [`limits_concurrency`](https://github.com/rails/solid_queue#concurrency-controls)
129
- caps simultaneous executions sharing a key, but explicitly does not guarantee
130
- their execution order. Solid Objects turns each actor identity into an ordered
131
- mailbox.
132
-
133
- ### Fenced activation
134
-
135
- A lease expiration by itself cannot stop a paused worker from resuming with
136
- stale state. Solid Objects combines the lease owner with a monotonically
137
- increasing activation generation. Every state commit verifies the current
138
- owner, generation, unexpired database-time lease, and claimed-message
139
- membership.
140
-
141
- A stale worker may finish running Ruby code, but it cannot commit stale state,
142
- complete the message, or publish outbox entries.
143
-
144
- ### Addressable objects with durable state
145
-
146
- An actor is addressed by `(actor_type, actor_id)`, not by a process, thread, or
147
- database row ID. Code anywhere in the application can refer to the same logical
148
- cart, room, device, or workflow. Its JSON state survives worker restarts and
149
- idle deactivation.
150
-
151
- ### Per-object alarms
152
-
153
- Cloudflare Durable Objects give each object an alarm. Rails recurring schedules
154
- are normally global task definitions. Solid Objects ports per-object alarms as
155
- durable reminders owned by one logical identity:
156
-
157
- ```ruby
158
- def schedule_expiration
159
- schedule(at: 30.minutes.from_now).expire
160
- end
161
- ```
162
-
163
- When due, a reminder becomes an ordinary mailbox message and follows the same
164
- ordering, retry, lease, and fencing rules as every other turn.
165
-
166
- ### Durable Objects that render themselves
167
-
168
- Cloudflare Durable Objects can coordinate WebSocket clients. Solid Objects adds
169
- a Rails-native extension: an actor observable becomes a live Turbo target with
170
- one helper call. The actor commit and durable broadcast outbox are atomic, so a
171
- rolled-back state change cannot leak into the page.
188
+ caps simultaneous executions sharing a key but explicitly does not guarantee
189
+ their order. Every commit verifies an activation generation, the lease owner,
190
+ an unexpired database-time lease, and claimed-message membership, so a stale
191
+ worker can finish running Ruby but cannot commit.
172
192
 
173
193
  ## Reactive ERB
174
194
 
175
- Define an observable:
176
-
177
- ```ruby
178
- class ChatRoom < SolidObjects::Actor
179
- attribute :recent_messages, default: -> { [] }
180
- attribute :status, default: "open"
181
-
182
- observable :message_count, broadcast: :value do
183
- recent_messages.length
184
- end
185
-
186
- observable :recent_messages
187
- observable :status
188
- end
189
- ```
190
-
191
- Scalar observables remain stable `<span>` targets:
195
+ For a comment count or a dashboard number, lock the row, update it, and call
196
+ `broadcast_replace_to`. That is less code than this gem and it works.
192
197
 
193
- ```erb
194
- <%= solid_object @room, authorization_context: current_user do |room| %>
195
- Messages: <%= room.message_count %>
196
- <% end %>
197
- ```
198
-
199
- Reactive components rerender a host ERB partial when one of their explicit
200
- dependencies changes:
201
-
202
- ```erb
203
- <%= solid_object @room, authorization_context: current_user do |room| %>
204
- <%= room.component :messages, observes: :recent_messages %>
205
- <%= room.component :presence, observes: %i[recent_messages status] %>
206
- <% end %>
207
- ```
208
-
209
- Observables are invalidation-only by default. Their values remain available to
210
- authorized component rendering, while durable rows and Action Cable frames
211
- carry only change metadata. Explicitly opt a scalar observable into sharing its
212
- value with every authorized actor subscriber:
213
-
214
- ```ruby
215
- observable :message_count, broadcast: :value do
216
- recent_messages.length
217
- end
218
- ```
198
+ It gets harder when several people write to the same record at once. Each
199
+ request renders the fragment in its own process and pushes it. The lock decided
200
+ who wrote first, but it has no say over which push arrives last, so a viewer
201
+ can be left looking at the older number. The second gap is that the push is not
202
+ part of the save: if the process dies after the database commits and before the
203
+ push goes out, the browser keeps a wrong number and nothing corrects it.
219
204
 
220
- Only `broadcast: :value` observables can render as scalar `<span>` targets.
221
- Their changed values are stored in `solid_objects_broadcasts` and can reach
222
- every subscriber that passes `authorize_subscription` for the actor. Put
223
- per-viewer state in `broadcast_payload`, which computes a fresh projection for
224
- each connection.
225
-
226
- Component names can repeat when each instance has a stable key. Signed
227
- JSON-compatible locals let one conventional partial render the matching
228
- projection:
205
+ An observable is the alternative. The state change and the broadcast row commit
206
+ together, a worker delivers that row and retries until it succeeds, and Cable
207
+ ignores an older `(instance_id, state_revision)` pair after a newer one. A
208
+ viewer cannot end up on an older number, though delivery itself is still at
209
+ least once.
229
210
 
230
211
  ```erb
231
- <%= solid_object @room, authorization_context: current_user do |room| %>
232
- <% @players.each do |player| %>
233
- <%= room.component :player,
234
- key: player.id,
235
- observes: %i[players life_totals],
236
- locals: { player_id: player.id },
237
- refresh: :morph %>
238
- <% end %>
212
+ <%= solid_object @sale, authorization_context: current_user do |sale| %>
213
+ <span class="seats"><%= sale.remaining %> seats left</span>
214
+ <%= sale.component :buyers, observes: :holds %>
239
215
  <% end %>
240
216
  ```
241
217
 
242
- The host partial still resolves only to `actors/chat_room/_player`. It receives
243
- `actor`, `authorization_context`, `component_key`, and the declared locals:
244
-
245
- ```erb
246
- <article id="player_<%= player_id %>">
247
- Life: <%= actor.life_totals.fetch(player_id.to_s) %>
248
- </article>
249
- ```
250
-
251
- The default refresh strategy is `:replace`. `refresh: :morph` loads the
252
- authorized component HTML through a gem-owned browser element, rejects stale
253
- responses by actor revision, and applies the result using Turbo's scoped
254
- `replace method="morph"`. Superseded requests for the same keyed target are
255
- aborted. This preserves unchanged DOM nodes where Turbo's morphing rules allow
256
- it, including focus and `data-turbo-permanent` content.
218
+ The two observables in the ticket sale are what make that template live: a
219
+ committed turn that changes `remaining` replaces the span, and one that changes
220
+ `holds` re-renders the component from `actors/ticket_sale/_buyers`. Observables
221
+ are invalidation-only unless declared `broadcast: :value`, which is why
222
+ `remaining` carries it and `holds` does not: only an opted-in scalar sends its
223
+ value to every authorized subscriber, and rendering an invalidation-only
224
+ observable as a span raises. Per-viewer state belongs in `broadcast_payload`.
225
+ Signed tokens protect integrity, not access: rendering, Cable, and every
226
+ refresh each authorize again.
257
227
 
258
- `room.component(:messages)` resolves only
259
- `actors/chat_room/_messages`. Its partial receives `actor` and
260
- `authorization_context` locals, plus a `component_key` of `nil` when the
261
- component is unkeyed:
262
-
263
- ```erb
264
- <ul>
265
- <% actor.recent_messages.each do |message| %>
266
- <li><%= message.fetch("body") %></li>
267
- <% end %>
268
- </ul>
269
- ```
270
-
271
- Declared observables are deeply frozen ordinary Ruby values inside a
272
- component. Arrays support loops, hashes support ordinary lookup, conditionals
273
- work normally, and ERB still escapes user strings. A reactive component cannot
274
- read `actor.state`, access an undeclared observable, or choose a dynamic
275
- partial path. A component name and key pair must be unique within its
276
- `solid_object` scope.
277
-
278
- Component keys and locals are signed into the refresh token and cannot be
279
- modified without invalidating it, but they are visible to the browser and are
280
- not secrets. Every initial render and refresh passes the signed locals and
281
- `component_key` to `authorize_query` as `arguments`. Authorization must still
282
- bind them to the authenticated request context.
283
-
284
- That template provides initial server rendering, stable opaque DOM targets,
285
- and live updates after committed actor turns. One `solid_object` block makes
286
- one Action Cable subscription for all scalar values and components inside it,
287
- and Action Cable multiplexes subscriptions over the browser's WebSocket.
288
-
289
- No client-side state store, custom Stimulus controller, channel class, manual
290
- broadcast, or one-WebSocket-per-value setup is required. Signed stream tokens
291
- protect integrity, not access. Initial rendering authorizes with the
292
- `authorization_context` passed to `solid_object`; Cable authorizes with its
293
- connection; every component refresh authorizes again with a request-specific
294
- context:
295
-
296
- ```ruby
297
- SolidObjects.configure do |configuration|
298
- configuration.component_authorization_context = ->(controller:) { Current.user }
299
- end
300
- ```
301
-
302
- The durable outbox stores one row per changed observable, never personalized
303
- HTML. Cable sends invalidation metadata over the shared actor stream, then a
304
- Turbo Frame requests the component with normal cookies. Only scalar targets
305
- that the server rendered into this `solid_object` scope are signed into its
306
- stream token and receive value payloads; component-only dependencies do not
307
- send their values to the browser. The endpoint renders the latest committed
308
- snapshot, returns `private, no-store`, and reauthorizes the component name plus
309
- every declared dependency. Two viewers can therefore receive different HTML
310
- for the same actor without sharing either projection.
311
-
312
- Reconnect compares the component's signed initial revision with the latest
313
- actor incarnation and state revision, then refreshes stale components. Cable
314
- coalesces several dependency changes from one actor turn into one component
315
- refresh and ignores older out-of-order invalidations. Replace refreshes detach
316
- an older in-flight frame. Morph refreshes abort the older request and compare
317
- the returned revision with the current target before applying HTML.
318
-
319
- Reactive components add no HTML to durable rows, but each affected component
320
- causes an authorized HTTP render. One actor turn still inserts one broadcast
321
- row per changed observable; several dependencies from that turn coalesce at
322
- the subscriber. Keep components bounded, declare only necessary dependencies,
323
- keep signed locals small, and use scalar observables for inexpensive
324
- single-value replacement. Each keyed component counts toward the 50-component
325
- subscription limit and carries its own signed token.
326
-
327
- Reactive views require `turbo-rails` and a working Action Cable adapter in the
328
- host application. The Solid Objects engine must be mounted so its signed
329
- component endpoint is reachable. Reactive views are optional; the actor
330
- runtime itself does not depend on Turbo. Morph components automatically include
331
- the engine's `solid_objects/component_refresh` JavaScript module; the host does
332
- not need a Stimulus controller or custom stream action. The default Rails
333
- Propshaft and Sprockets setups discover namespaced engine assets automatically.
334
- An application created with `--skip-asset-pipeline` should use replace refreshes
335
- unless it explicitly serves that module.
336
-
337
- ```ruby
338
- # config/routes.rb
339
- mount SolidObjects::Engine => "/solid_objects"
340
- ```
228
+ Reactive views require `turbo-rails`, an Action Cable adapter, and
229
+ `mount SolidObjects::Engine => "/solid_objects"`. They are optional; the actor
230
+ runtime does not depend on Turbo. The [realtime guide](docs/realtime.md) covers
231
+ keyed components and signed locals, `refresh: :morph`, reconnect fencing,
232
+ subscription limits, and the per-refresh cost model.
341
233
 
342
234
  ## Installation
343
235
 
344
- Solid Objects requires Ruby 3.3 or newer and Rails 7.1 or newer. CI runs the
345
- suite against Rails 7.1, 7.2, 8.0, and 8.1.
346
-
347
- Add the gem, install its initializer and migration, then migrate:
236
+ Ruby 3.3 or newer and Rails 7.1 or newer. CI runs the suite against Rails 7.1,
237
+ 7.2, 8.0, and 8.1.
348
238
 
349
239
  ```bash
350
240
  bundle add solid_objects
@@ -353,100 +243,17 @@ bin/rails db:migrate
353
243
  bin/rails solid_objects:doctor
354
244
  ```
355
245
 
356
- The doctor validates configuration and required schema shape, reports
357
- authorization posture and live runtime roles, and completes a real synchronous
358
- actor round-trip without a worker. It checks required tables and columns instead
359
- of a copied migration timestamp, which the host application rewrites. It exits
360
- unsuccessfully when configuration, schema, or the round-trip is broken.
246
+ The doctor validates configuration and schema shape, reports authorization
247
+ posture and live roles, and completes a real synchronous actor round-trip
248
+ without a worker.
361
249
 
362
250
  The generated initializer is intentionally inert: all five policies deny by
363
- default. Replace them with application-specific authorization before sending
364
- messages, querying state, destroying actors, subscribing to streams, or
365
- mounting administration routes:
366
-
367
- ```ruby
368
- SolidObjects.configure do |configuration|
369
- configuration.authorize_message = ->(**) { false }
370
- configuration.authorize_query = ->(**) { false }
371
- configuration.authorize_destroy = ->(**) { false }
372
- configuration.authorize_subscription = ->(**) { false }
373
- configuration.authorize_administration = ->(**) { false }
374
- end
375
- ```
376
-
377
- Knowledge of an actor ID or signed stream token is never authorization.
378
- Read the [policy reference and tenant-aware example](docs/authorization.md)
379
- before opening a policy. Unconditionally allowing message and query calls is
380
- reasonable only for a controlled server-side pilot. Keep destroy,
381
- subscription, and administration denied until each has an authenticated
382
- caller.
383
-
384
- The engine uses the application's primary Active Record connection by default.
385
- See [Database support](#database-support) for a separate database configuration.
386
-
387
- ### Host application tooling
388
-
389
- Installed engine migrations are copied as
390
- `db/migrate/*_create_solid_objects_tables.solid_objects.rb`. If the host enables
391
- `Rails/CreateTableWithTimestamps`, exclude engine-owned migrations rather than
392
- editing their intentionally specialized hot tables:
393
-
394
- ```yaml
395
- Rails/CreateTableWithTimestamps:
396
- Exclude:
397
- - "db/migrate/*.solid_objects.rb"
398
- ```
399
-
400
- Solid Objects ships inline RBS signatures, not RBI files. Sorbet applications
401
- can generate the gem RBI with:
402
-
403
- ```bash
404
- bundle exec tapioca gem solid_objects
405
- ```
406
-
407
- ## Upgrading
408
-
409
- Review [CHANGELOG.md](CHANGELOG.md) for compatibility and deployment-order
410
- notes, then update the gem:
411
-
412
- ```bash
413
- bundle update solid_objects
414
- ```
415
-
416
- If the `Gemfile` pins an exact version, update that constraint first and run
417
- `bundle install`. Commit both `Gemfile.lock` and the copied Solid Objects
418
- migrations.
419
-
420
- Copy only migrations that the newer gem has added, migrate, and verify the
421
- installation:
422
-
423
- ```bash
424
- bin/rails solid_objects:install:migrations
425
- bin/rails db:migrate
426
- bin/rails solid_objects:doctor
427
- ```
428
-
429
- The migration task skips engine migrations already present in the application
430
- and gives new migrations host-specific timestamps. Inspect the resulting
431
- `db/migrate/*.solid_objects.rb` files before applying them. Do not rerun
432
- `generate solid_objects:install` during an upgrade because that also attempts
433
- to regenerate the application initializer.
434
-
435
- When Solid Objects uses a separate database configuration named `actors`, copy
436
- and run migrations through that database's configured migration path:
437
-
438
- ```bash
439
- DATABASE=actors bin/rails solid_objects:install:migrations
440
- bin/rails db:migrate:actors
441
- bin/rails solid_objects:doctor
442
- ```
443
-
444
- For production, back up the actor database and run new migrations before
445
- starting application or Solid Objects worker processes that require the new
446
- schema. Restart the web and Solid Objects worker fleet after the bundle and
447
- schema are current. For releases that change actor state versions, also follow
448
- the [state migration and rolling-deployment guide](docs/state-migrations.md);
449
- Rails schema migrations and actor state migrations are separate concerns.
251
+ default. Replace them before sending messages, querying state, destroying
252
+ actors, subscribing to streams, or mounting administration routes. Knowledge of
253
+ an actor ID or a signed stream token is never authorization. Read the
254
+ [policy reference](docs/authorization.md) first. Upgrades, the RuboCop
255
+ exclusion for engine migrations, and Sorbet RBI generation are in the
256
+ [operations guide](docs/operations.md#installing-and-upgrading).
450
257
 
451
258
  ## Worker requirements
452
259
 
@@ -455,909 +262,319 @@ the runtime when the feature introduces asynchronous delivery or outboxes:
455
262
 
456
263
  | Feature | Runtime roles required |
457
264
  | --- | --- |
458
- | Direct actor method or explicit `sync` | None; the caller executes it |
459
- | Attribute or declared query read | None; the caller executes it |
460
- | Committed `snapshot` read | None; reads the instance row directly |
461
- | `destroy` | None |
265
+ | Direct actor method, `sync`, query read, `snapshot`, or `destroy` | None; the caller executes it |
462
266
  | `async` including delayed delivery | Actor worker |
463
267
  | One-shot or recurring `schedule` | Reminder scheduler and actor worker |
464
- | `emit` without an actor callback | Effect worker |
465
- | `emit` with success or failure callback | Effect worker and actor worker |
268
+ | `emit`, with or without an actor callback | Effect worker, plus actor worker for callbacks |
466
269
  | Actor-to-actor `async` or `send_to` | Effect worker and actor worker |
467
270
  | Scalar or component Turbo updates | Broadcast worker, Action Cable, and the actor execution path |
468
- | Initial `solid_object` server render | No Solid Objects worker; normal Rails rendering |
469
-
470
- One command starts every Solid Objects role:
471
271
 
472
- ```bash
473
- bundle exec solid_objects start
474
- ```
475
-
476
- Deploy and monitor that process before enabling any feature marked as requiring
477
- a runtime role. A missing worker never makes a durable `async` message
478
- disappear, but it leaves the message pending indefinitely.
479
-
480
- ### Running an extension in the same process
481
-
482
- An extension gem can register its own long-running component, and
483
- `solid_objects start` runs it beside the built-in roles. The component joins the
484
- same supervision, the same replacement after a crash, and the same shutdown
485
- timeout, so an operator deploys and monitors one process instead of two:
486
-
487
- ```ruby
488
- SolidObjects.configure do |configuration|
489
- configuration.register_component { MyExtension::FlushEngine.new }
490
- end
491
- ```
492
-
493
- Pass `count:` for more than one instance. The block runs once for each instance,
494
- and again when the supervisor replaces a crashed one, so no two components share
495
- an object.
496
-
497
- A registered component answers four methods, the contract the built-in roles
498
- already keep:
499
-
500
- | Method | Purpose |
501
- | --- | --- |
502
- | `run` | Runs the loop. The supervisor calls it in its own thread |
503
- | `request_shutdown` | Asks the loop to finish. It must make `run` return |
504
- | `stopped?` | Reports whether the component already finished |
505
- | `stop` | Forces cleanup when the shutdown timeout expires first |
506
-
507
- The supervisor checks that contract when it builds the component, and a missing
508
- method raises `ArgumentError` as the supervisor starts, rather than hanging a
509
- shutdown later. Registration itself never calls the block, so a component is
510
- free to need a database connection that the application does not have while it
511
- boots.
272
+ `bundle exec solid_objects start` runs every role. A missing worker never makes
273
+ a durable `async` message disappear, but it leaves the message pending
274
+ indefinitely. An extension gem can register its own long-running component to
275
+ run beside the built-in roles; see the
276
+ [operations guide](docs/operations.md#running-an-extension-in-the-same-process).
512
277
 
513
278
  ## Defining an actor
514
279
 
515
- The Durable Object class becomes an ordinary Ruby class:
516
-
517
- ```ruby
518
- class ShoppingCart < SolidObjects::Actor
519
- attribute :items, default: -> { [] }
520
- attribute :checkout_status, default: "open"
521
-
522
- def add_item(product_id:, quantity: 1)
523
- item = items.find do |candidate|
524
- candidate.fetch("product_id") == product_id
525
- end
526
-
527
- if item
528
- item["quantity"] += quantity
529
- else
530
- items << {
531
- "product_id" => product_id,
532
- "quantity" => quantity
533
- }
534
- end
535
- end
536
-
537
- observable :items_count, broadcast: :value do
538
- items.sum { |item| item.fetch("quantity") }
539
- end
540
- end
541
- ```
542
-
543
- Class-level `attribute` declarations are the per-object durable storage schema
544
- and generate actor instance readers and writers. Public instance methods
545
- declared on the actor are durable message handlers. They can use `items`,
546
- `self.checkout_status = "pending"`, or the lower-level `state` object. Declare
547
- helper methods as private or protected so they are not exposed as messages.
548
-
549
- Attributes also become ordered read queries on a reference. Public actor
550
- methods and attribute readers are synchronous caller-assisted invocations:
551
-
552
- ```ruby
553
- cart = ShoppingCart.ref("alice")
554
- cart.add_item(product_id: "shirt-123", quantity: 2)
555
- items = cart.items
556
- ```
557
-
558
- Use `cart.async.add_item(product_id: "shirt-123", quantity: 2)` to enqueue
559
- without waiting; that call returns a `SolidObjects::MessageReference`. `items`
560
- is a deeply frozen JSON snapshot, so mutating it cannot bypass the actor
561
- mailbox. State changes must go through public actor methods or explicit
562
- `async`.
280
+ `TicketSale` above is the whole shape. Class-level `attribute` declarations are
281
+ the per-object durable storage schema. Public instance methods are durable
282
+ message handlers, so declare helpers private. Attributes also become ordered
283
+ read queries on a reference: `sale.remaining` goes through the mailbox, while
284
+ `sale.snapshot.remaining` reads the most recently committed state without one
285
+ and does not activate a missing actor.
563
286
 
564
287
  State, arguments, results, effects, and reminder arguments accept
565
- JSON-compatible values. Solid Objects never deserializes Ruby `Marshal` data.
566
-
567
- Attribute readers are ordered mailbox queries and retain message history. For
568
- a read that does not need mailbox ordering, use an authorized committed
569
- snapshot:
288
+ JSON-compatible values only, and Solid Objects never deserializes Ruby
289
+ `Marshal` data. Returned values are deeply frozen, so mutating one cannot
290
+ bypass the mailbox; use `SolidObjects.mutable_copy(value)` to change a copy.
570
291
 
571
- ```ruby
572
- snapshot = cart.snapshot
573
- items = snapshot.items
574
- ```
575
-
576
- Snapshots and synchronous results are deeply frozen. Use
577
- `SolidObjects.mutable_copy(items)` before changing a returned collection.
578
- Snapshot reads can race with an in-flight turn; they return the most recently
579
- committed state and do not create or activate a missing actor.
580
-
581
- Lifecycle hooks are also available:
582
-
583
- ```ruby
584
- class DeviceActor < SolidObjects::Actor
585
- on_activate do
586
- end
292
+ Lifecycle hooks `on_activate` and `on_deactivate` are available. They should be
293
+ deterministic and must not perform slow network I/O. See the
294
+ [architecture guide](docs/architecture.md) for their persistence semantics.
587
295
 
588
- on_deactivate do
589
- end
590
- end
591
- ```
592
-
593
- Hooks should be deterministic and must not perform slow network I/O. See the
594
- [architecture](docs/architecture.md) for their persistence semantics.
595
-
596
- ## Actor identity
597
-
598
- The durable identity is:
599
-
600
- ```text
601
- actor_type + actor_id
602
- ```
603
-
604
- `actor_type` is inferred from the Ruby class name, so the normal API needs no
605
- declaration. The pair plays the role of a Durable Objects namespace and object
606
- name:
607
-
608
- ```ruby
609
- ShoppingCart.ref("alice")
610
- ```
611
-
612
- Use an explicit stable type when the persisted name should be independent of a
613
- future Ruby constant rename:
614
-
615
- ```ruby
616
- class ShoppingCart < SolidObjects::Actor
617
- actor_type "shopping_cart"
618
- end
619
- ```
620
-
621
- Actor types resolve only through the explicit registry. Solid Objects never
622
- constantizes a type supplied by a client.
296
+ The durable identity is `actor_type` plus `actor_id`, which together play the
297
+ role of a Durable Objects namespace and object name. The type is inferred from
298
+ the class name; declare `actor_type "ticket_sale"` when the persisted name
299
+ should survive a constant rename. Types resolve only through the explicit
300
+ registry, and Solid Objects never constantizes a type supplied by a client.
623
301
 
624
302
  ## Invoking an object
625
303
 
626
- As with a Durable Object stub, declared actor operations are available directly
627
- on a reference:
628
-
629
- ```ruby
630
- class Counter < SolidObjects::Actor
631
- attribute :value, default: 0
632
-
633
- def increment(amount: 1)
634
- self.value += amount
635
- end
636
- end
637
-
638
- counter = Counter.ref("global")
639
- value = counter.increment(amount: 5)
640
- value = counter.value
641
- ```
642
-
643
- Like RPC on a Durable Object stub, a direct call is synchronous from the
644
- caller's perspective. Solid Objects first durably enqueues the invocation, then
645
- executes that actor locally when its fenced activation is available. It returns
646
- the committed, deeply frozen result. Earlier mailbox entries still run first,
647
- and a remote worker may win the activation without changing the result
648
- semantics.
649
-
650
- The `message(:name) { ... }` and `query(:name) { ... }` DSLs remain available
651
- for dynamic definitions.
304
+ As with a Durable Object stub, declared operations are available directly on a
305
+ reference. A direct call is synchronous from the caller's perspective: Solid
306
+ Objects durably enqueues it, executes the actor locally when its fenced
307
+ activation is available, and returns the committed, deeply frozen result.
652
308
 
653
- ### `async`
654
-
655
- Use `async` for durable fire-and-forget work. It returns a
656
- `MessageReference` immediately and leaves execution to the worker fleet:
657
-
658
- ```ruby
659
- message = order.async(
660
- idempotency_key: "submit-order-123",
661
- authorization_context: Current.user
662
- ).submit
663
- ```
664
-
665
- `async` needs a running actor worker. Installing the engine and migrating the
666
- schema starts no role, so a process that only serves web requests leaves the
667
- message ready. Nothing is lost. The message waits until
668
- `bundle exec solid_objects start` runs the roles. See
669
- [Worker requirements](#worker-requirements) for the feature-by-role table.
670
-
671
- Use `available_at:` to spread bulk work or delay one message:
309
+ Use `async` for durable fire-and-forget work, and explicit `sync` for a
310
+ timeout, idempotency key, or authorization context different from the defaults:
672
311
 
673
312
  ```ruby
313
+ order.async(idempotency_key: "submit-order-123").submit
674
314
  order.async(available_at: 10.minutes.from_now).evaluate
315
+ order.sync(timeout: 5.seconds, authorization_context: Current.user).status
675
316
  ```
676
317
 
677
- ### `sync`
678
-
679
- Use explicit `sync` when the invocation needs a timeout, idempotency key, or
680
- authorization context different from the defaults:
681
-
682
- ```ruby
683
- status = order.sync(
684
- timeout: 5.seconds,
685
- authorization_context: Current.user
686
- ).status
687
- ```
318
+ `async` needs a running actor worker, and installing the engine starts no role.
319
+ Until `bundle exec solid_objects start` runs, the message stays ready rather
320
+ than lost.
688
321
 
689
322
  Delivery configuration belongs on `async(...)` or `sync(...)` before the
690
- operation. Keywords on the final method call are always actor message
691
- arguments, so `order.sync(timeout: 5.seconds).record(timeout: "payload")`
692
- keeps the invocation timeout separate from the payload value.
693
-
694
- Direct calls and `sync` use the same caller-assisted execution path. A healthy
695
- actor normally needs no worker round trip, making this path suitable for HTTP
696
- and MCP request/response boundaries when the handler itself fits the
697
- application's latency budget. If another process owns the activation, the
698
- caller waits for the durable result using wake-up hints with bounded database
699
- polling as the fallback. A timeout never cancels the durable invocation.
700
- `SolidObjects::SyncTimeout` includes actor identity, message ID, sequence,
701
- durable status, mailbox blocker, and activation-owner diagnostics without
702
- including message arguments. The configured timeout also bounds adapter
703
- database lock waits from the enqueue attempt through result observation.
704
- PostgreSQL uses transaction lock and statement timeouts, SQLite retries busy
705
- coordination operations only until the original call deadline, and MySQL uses
706
- its execution timeout plus InnoDB's one-second minimum lock-wait granularity.
707
-
708
- The durable call can finish after its original caller gives up. Reauthorize and
709
- recover its eventual result through the durable message identity:
710
-
711
- ```ruby
712
- begin
713
- order.sync(timeout: 250.milliseconds).submit
714
- rescue SolidObjects::SyncTimeout => error
715
- result = error.message_reference.wait(
716
- timeout: 5.seconds,
717
- authorization_context: Current.user
718
- )
719
- end
720
- ```
721
-
722
- If the enqueue transaction itself cannot finish within the budget, Solid
723
- Objects raises `SyncEnqueueTimeout`; no durable message exists to recover.
724
- Timeouts do not preempt Ruby handler code that has already started.
323
+ operation, so keywords on the final call are always message arguments:
324
+ `order.sync(timeout: 5.seconds).record(timeout: "payload")` keeps the two
325
+ apart. A timeout never cancels the durable invocation, so the call can finish
326
+ after its caller gives up; `SolidObjects::SyncTimeout` carries a
327
+ `message_reference` that can reauthorize and wait for the result. Do not wrap a
328
+ synchronous call in `ApplicationRecord.transaction`: Solid Objects raises
329
+ `SolidObjects::SyncInsideTransaction` before enqueue.
725
330
 
726
- Do not wrap a synchronous actor call in `ApplicationRecord.transaction`.
727
- Solid Objects raises `SolidObjects::SyncInsideTransaction` before enqueue when
728
- its connection already has an open transaction. Move the actor call before the
729
- transaction, use `async`, or let the actor own the coordinated change through a
730
- commit action.
731
-
732
- Actor code cannot use direct calls or `sync` on another actor; synchronous
733
- actor-to-actor waits can deadlock in cycles. Use `async` or `send_to` and a
734
- result message.
331
+ Actor code cannot use direct calls or `sync` on another actor, because
332
+ synchronous actor-to-actor waits can deadlock in cycles. Use `async` or
333
+ `send_to`, which stages delivery with the current turn, returns `nil`, is
334
+ discarded if the turn does not commit, and accepts messages rather than
335
+ queries:
735
336
 
736
337
  ```ruby
737
- send_to(
738
- audit_log,
739
- available_at: 5.minutes.from_now,
740
- idempotency_key: event_id
741
- ).record(event_id:, event_name: "account_disabled")
742
- ```
743
-
744
- Actor-to-actor delivery is staged with the current turn, returns `nil`, and is
745
- discarded if that turn does not commit. It accepts messages, not queries.
746
-
747
- ### Domain rejection
748
-
749
- Reject invalid input without retrying or creating a dead letter:
750
-
751
- ```ruby
752
- def submit(response:)
753
- reject :validation_failed, "Response is not valid" unless valid?(response)
754
-
755
- self.response = response
756
- end
338
+ send_to(audit_log, idempotency_key: event_id).record(event_name: "account_disabled")
757
339
  ```
758
340
 
759
- The caller receives `SolidObjects::Rejected` with a stable code, message, and
760
- JSON-compatible details. The rejected message remains durable for audit, actor
761
- state is rolled back, and no later mailbox turn is blocked.
341
+ ### Domain rejection and redelivery
762
342
 
763
- `Rejected#code` is a `String`, even when `reject` receives a symbol. Codes must
764
- match `\A[A-Za-z_][A-Za-z0-9_]*\z`. Invalid codes raise
765
- `SolidObjects::InvalidRejectionCode` and fail the turn without retrying.
343
+ `reject :validation_failed, "Response is not valid"` ends a turn without
344
+ retrying or dead-lettering. The caller receives `SolidObjects::Rejected` with a
345
+ stable code, message, and JSON-compatible details; the rejected message stays
346
+ durable for audit, actor state rolls back, and no later turn is blocked. A code
347
+ must match `\A[A-Za-z_][A-Za-z0-9_]*\z`, and an invalid one raises
348
+ `SolidObjects::InvalidRejectionCode`.
766
349
 
767
- ### Redelivery
768
-
769
- Sequential does not mean once. A handler can run again after a process crash or
770
- lease loss, so guard logical transitions in durable actor state:
771
-
772
- ```ruby
773
- def launch
774
- return if status == "launched"
775
-
776
- self.status = "launched"
777
- emit :launch_vehicle, launch_id: actor_id
778
- end
779
- ```
780
-
781
- External systems must also deduplicate effects using the stable effect ID.
350
+ Sequential does not mean once. A handler can run again after a crash or lease
351
+ loss, so guard logical transitions in durable actor state and deduplicate
352
+ external effects on the stable effect ID. See
353
+ [handler idempotency](docs/correctness.md#handler-idempotency).
782
354
 
783
355
  ## Application database writes
784
356
 
785
357
  Actor handlers execute outside the fenced commit. They may query application
786
358
  records, but Solid Objects rejects direct Active Record writes from all
787
- user-supplied actor code: handlers, observables, activation/deactivation hooks,
788
- and state migrations. Otherwise an application row could commit before the
789
- actor later raises or loses its activation fence.
359
+ user-supplied actor code, so an application row cannot commit before the actor
360
+ later raises or loses its fence.
790
361
 
791
362
  For a short database-only change that must commit atomically with actor state,
792
- stage a named action:
363
+ stage a named action and register its implementation at boot. The registered
364
+ block runs inside the fenced transaction, so its writes, actor state, message
365
+ completion, and outboxes commit or roll back together:
793
366
 
794
367
  ```ruby
795
- class Assessment < SolidObjects::Actor
796
- attribute :status, default: "open"
797
-
798
- def finish(attempt_id:, score:)
799
- self.status = "complete"
800
- commit_action :complete_attempt, attempt_id:, score:
801
- end
368
+ def finish(attempt_id:, score:)
369
+ self.status = "complete"
370
+ commit_action :complete_attempt, attempt_id:, score:
802
371
  end
803
372
  ```
804
373
 
805
- Register its implementation during application boot:
806
-
807
- ```ruby
808
- SolidObjects.register_commit_action(:complete_attempt) do |arguments, context|
809
- AssessmentAttempt.find(arguments.fetch("attempt_id")).update!(
810
- score: arguments.fetch("score"),
811
- actor_message_id: context.message_id
812
- )
813
- end
814
- ```
815
-
816
- The registered block runs inside the short fenced transaction. Its database
817
- writes, actor state, message completion, and outboxes all commit or roll back
818
- together. Commit actions require Solid Objects and `ActiveRecord::Base` to
819
- share one connection pool. They may be invoked again after a database rollback,
820
- so keep them deterministic, bounded, and database-only. Never perform network
821
- I/O, wait for another actor, or enqueue nontransactional work from a commit
822
- action.
823
-
824
- When Solid Objects uses a separate actor database, use `emit` and an idempotent
825
- effect consumer instead; the two databases cannot share one transaction.
374
+ See
375
+ [registering handlers](docs/architecture.md#registering-effect-and-commit-action-handlers).
826
376
 
827
377
  ## Effects
828
378
 
829
- Cloudflare Durable Objects can call external services directly. Solid Objects
830
- does not hold a Rails database transaction across slow external I/O. `emit`
831
- creates a transactional outbox entry alongside state and message completion:
379
+ Solid Objects does not hold a database transaction across slow external I/O.
380
+ `emit` stages a transactional outbox entry alongside state and message
381
+ completion, and an effect worker performs the call afterwards:
832
382
 
833
383
  ```ruby
834
- def checkout(payment_id:, amount_cents:)
835
- return unless checkout_status == "open"
836
-
384
+ def checkout(payment_id:)
837
385
  self.checkout_status = "pending"
838
- emit(
839
- :charge_payment,
840
- payment_id:,
841
- amount_cents:,
842
- on_success: :payment_succeeded,
843
- on_failure: :payment_failed
844
- )
845
- end
846
-
847
- def payment_succeeded(effect_id:, arguments:, result:)
848
- self.checkout_status = "paid"
849
- end
850
-
851
- def payment_failed(effect_id:, arguments:, error:)
852
- self.checkout_status = "failed"
853
- end
854
- ```
855
-
856
- Register an effect handler during application boot:
857
-
858
- ```ruby
859
- SolidObjects.register_effect(:charge_payment) do |arguments, context|
860
- Payments.charge(
861
- idempotency_key: context.id,
862
- payment_id: arguments.fetch("payment_id"),
863
- amount_cents: arguments.fetch("amount_cents")
864
- )
386
+ emit(:charge_payment, payment_id:,
387
+ on_success: :payment_succeeded, on_failure: :payment_failed)
865
388
  end
866
389
  ```
867
390
 
868
- The provider call can repeat if a process dies after external success but
869
- before recording completion. The stable effect ID is the idempotency key.
870
- Success callbacks receive `effect_id:`, the originally staged `arguments:`,
871
- and `result:`. Failure callbacks receive `effect_id:`, `arguments:`, and
872
- `error:`, so an actor can correlate concurrent effects without storing a
873
- separate callback ledger.
391
+ A success callback receives `effect_id:`, `arguments:`, and `result:`. The
392
+ provider call can repeat if a process dies after external success but before
393
+ recording completion, so the consumer must deduplicate on the stable effect ID.
394
+ See [registering handlers](docs/architecture.md#registering-effect-and-commit-action-handlers).
874
395
 
875
396
  ## Reminders
876
397
 
877
- Reminders are Solid Objects' durable equivalent of the Durable Objects Alarms
878
- API. One-shot and recurring alarms are actor-owned database records:
879
-
880
- ```ruby
881
- def schedule_evaluation
882
- schedule(
883
- at: 1.hour.from_now,
884
- every: 1.hour,
885
- missed: :latest
886
- ).evaluate(account_id:)
887
- end
888
- ```
889
-
890
- Use `missed: :latest` to coalesce missed occurrences or `missed: :all` to
891
- enqueue each one.
892
-
893
- ### A reminder is one named alarm per actor
894
-
895
- The uniqueness key is `(actor, reminder name)`. Scheduling a name that is
896
- already armed **moves the existing alarm** rather than adding a second one. The
897
- database enforces this with a unique index on `(instance_id, name)`.
898
-
899
- This is the same model as Orleans reminders and Durable Objects alarms, and it
900
- is what makes a reminder safe to re-arm from a handler that may run more than
901
- once. Without a key the name is the operation, so this is a data-loss bug:
902
-
903
- ```ruby
904
- # Wrong. Every entry overwrites the previous entry's alarm.
905
- def add(entry:)
906
- self.entries = entries + [ entry ]
907
- schedule(at: entry.fetch("wait_until")).deliver
908
- end
909
- ```
910
-
911
- Two entries leave one reminder. The earlier wake-up never happens, nothing
912
- raises, and nothing is logged except a `solid_objects.reminder.replaced` event.
913
-
914
- ### An alarm per item, with `key:`
915
-
916
- Pass `key:` when an actor is waiting on several things at once. The key is your
917
- own identifier for the item, and it names that item's alarm, so each item gets
918
- one:
919
-
920
- ```ruby
921
- def add(entry:)
922
- self.entries = entries + [ entry ]
923
- schedule(at: entry.fetch("wait_until"), key: entry.fetch("id")).deliver
924
- end
925
- ```
926
-
927
- Two entries now leave two reminders. Scheduling the same key again moves that
928
- item's alarm and leaves the others alone, which is what makes a keyed reminder
929
- as safe to re-arm as an unkeyed one. The operation still decides which handler
930
- runs; the key only decides which alarm is which.
931
-
932
- A key must be non-empty, and the name it becomes must fit the 191-character
933
- column, which is checked on the composed name rather than the key alone so a
934
- long operation and a short key are caught too.
935
-
936
- The key is separated from the operation by a colon, so an operation may not hold
937
- one. Otherwise an unkeyed `deliver:item` and a `deliver` keyed `item` would be
938
- one name, and the second would silently take the first one's alarm. A key may
939
- hold colons of its own, because the operation before the first one cannot.
940
-
941
- ### One alarm for a whole queue
942
-
943
- A key per item is not always what you want. An actor that only ever needs to
944
- know "what is next" can keep one alarm and let the handler drain everything now
945
- due before arming the next:
398
+ A reminder is a per-object alarm. When due it becomes an ordinary mailbox
399
+ message under the same ordering, retry, lease, and fencing rules as every other
400
+ turn:
946
401
 
947
402
  ```ruby
948
- def add(entry:)
949
- self.entries = (entries + [ entry ]).sort_by { |item| item.fetch("wait_until") }
950
- arm_next
951
- end
952
-
953
- def deliver
954
- now = Time.current.to_i
955
- due, pending = entries.partition { |item| item.fetch("wait_until") <= now }
956
- due.each { |item| emit :send_push, **item.symbolize_keys }
957
- self.entries = pending
958
- arm_next
959
- end
960
-
961
- private
962
-
963
- def arm_next
964
- earliest = entries.first
965
- return unless earliest
966
-
967
- schedule(at: Time.at(earliest.fetch("wait_until"))).deliver
968
- end
403
+ schedule(at: 30.minutes.from_now).expire
404
+ schedule(at: entry.fetch("wait_until"), key: entry.fetch("id")).deliver
969
405
  ```
970
406
 
971
- That costs one reminder row instead of one per item, and a coalesced occurrence
972
- cannot strand an entry because the handler drains by time rather than by alarm.
973
- Prefer it when the queue is large and the items are interchangeable; prefer
974
- `key:` when an item needs its own alarm that can be moved on its own.
975
-
976
- Solid Objects has no `unschedule`. A reminder stops when its handler does not
977
- re-arm it, and destroying an actor removes its reminders.
978
-
979
- Self-scheduling actors should also have a low-frequency application reconciler.
980
- It may read `SolidObjects::Instance.states_for`, `.without_pending_work`, and
981
- `.orphaned`, but every repair must go through `async`. Never bulk-update actor
982
- state around the lease and fencing checks.
407
+ A reminder is named for its operation, so re-arming `expire` moves the same
408
+ alarm rather than adding one. Pass `key:` when the actor waits on several items
409
+ at once and each needs its own alarm. Without a key, scheduling per item is a
410
+ data-loss bug: every entry overwrites the previous entry's alarm, nothing
411
+ raises, and only a `solid_objects.reminder.replaced` event records it.
983
412
 
984
- Suspended actors should be reported rather than silently resumed. Spread large
985
- repair batches with `available_at:` so reconciliation cannot stampede one
986
- mailbox or the worker fleet.
413
+ There is no `unschedule`; a reminder stops when its handler does not re-arm it.
414
+ The [reminders guide](docs/reminders.md) covers the naming rules, one alarm for
415
+ a whole queue, and reconciliation for self-scheduling actors.
987
416
 
988
417
  ## Destroying an object
989
418
 
990
- Destroy an actor incarnation through its reference:
991
-
992
- ```ruby
993
- Counter.ref("global").destroy
994
- ```
995
-
996
- `destroy` is synchronous and idempotent. It returns `true` when it deletes an
997
- existing incarnation and `false` when none exists. In one transaction it locks
998
- and deletes the actor instance; cascading foreign keys remove state, message
999
- history, ready and claimed mailbox rows, dead letters, reminders, effects, and
1000
- broadcasts.
1001
-
1002
- Destruction has its own deny-by-default `authorize_destroy` policy and cannot be
1003
- called synchronously from actor code. It does not run `on_deactivate`. A stale
1004
- activation cannot commit after deletion because its fenced write targets the
1005
- deleted instance primary key. Addressing the same type and ID later creates a
1006
- fresh incarnation with default state and message sequence 1.
1007
-
1008
- Pending outboxes are deleted. An external effect, actor-to-actor delivery, or
1009
- broadcast that already started cannot be recalled, but its stale completion
1010
- cannot enqueue a callback or recreate the source actor. See
1011
- [destruction semantics](docs/correctness.md#destruction) before using deletion
1012
- as application workflow.
419
+ `Counter.ref("global").destroy` is synchronous and idempotent. In one
420
+ transaction it locks and deletes the instance, and cascading foreign keys
421
+ remove state, message history, mailbox rows, dead letters, reminders, effects,
422
+ and broadcasts. It has its own deny-by-default `authorize_destroy` policy,
423
+ cannot be called synchronously from actor code, and does not run
424
+ `on_deactivate`. A stale activation cannot commit afterwards, and addressing
425
+ the same type and ID later creates a fresh incarnation with sequence 1. Read
426
+ [destruction semantics](docs/correctness.md#destruction) first.
1013
427
 
1014
428
  ## State migrations
1015
429
 
1016
- Actor state has an independent schema version:
430
+ Actor state has an independent schema version. An actor refuses activation when
431
+ stored state is newer than the running code, and published migration blocks
432
+ cannot be squashed, because a long-idle actor may still hold an old
433
+ representation:
1017
434
 
1018
435
  ```ruby
1019
- class ShoppingCart < SolidObjects::Actor
1020
- state_version 2
436
+ state_version 2
1021
437
 
1022
- migrate_state from: 1, to: 2 do |state|
1023
- state["currency"] ||= "USD"
1024
- state
1025
- end
438
+ migrate_state from: 1, to: 2 do |state|
439
+ state["currency"] ||= "USD"
440
+ state
1026
441
  end
1027
442
  ```
1028
443
 
1029
- An actor refuses activation when stored state is newer than the running code.
1030
- Published migration blocks cannot be squashed because a long-idle actor may
1031
- still hold an old representation. Destructive changes need an expand/contract
1032
- rolling deployment. Read the [state migration guide](docs/state-migrations.md)
1033
- before changing persisted state.
444
+ Read the [state migration guide](docs/state-migrations.md) before changing
445
+ persisted state.
1034
446
 
1035
447
  ## Configuration
1036
448
 
1037
- Configure Solid Objects in `config/initializers/solid_objects.rb`:
449
+ Configure Solid Objects in `config/initializers/solid_objects.rb`. Invalid
450
+ lease intervals, component counts, and size limits fail fast at boot. The
451
+ [operations guide](docs/operations.md#configuration) lists every setting with
452
+ its default, and covers polling intervals and cross-process wake-up adapters.
1038
453
 
1039
454
  ```ruby
1040
455
  SolidObjects.configure do |configuration|
1041
456
  configuration.worker_count = 4
1042
457
  configuration.lease_duration = 30.seconds
1043
- configuration.lease_renewal_interval = 10.seconds
1044
- configuration.max_messages_per_activation_pass = 50
1045
- configuration.max_activation_duration = 5.seconds
1046
458
  end
1047
459
  ```
1048
460
 
1049
- Important defaults:
1050
-
1051
- | Setting | Default |
1052
- | --- | ---: |
1053
- | `polling_interval` | 0.1 seconds |
1054
- | `idle_polling_interval` | 1 second |
1055
- | `sync_polling_interval` | 0.05 seconds |
1056
- | `lease_duration` | 30 seconds |
1057
- | `lease_renewal_interval` | 10 seconds |
1058
- | `idle_deactivation_timeout` | 30 seconds |
1059
- | `max_messages_per_activation_pass` | 50 |
1060
- | `max_activation_duration` | 5 seconds |
1061
- | `max_mailbox_length` | 10,000 |
1062
- | `max_attempts` | 5 |
1063
- | `process_heartbeat_interval` | 15 seconds |
1064
- | `process_alive_threshold` | 60 seconds |
1065
- | `message_retention` | 30 days |
1066
- | `message_retention_by_actor_type` | `{}` |
1067
- | `instance_retention_by_actor_type` | `{}`; instances never expire unless listed |
1068
- | `process_retention` | 7 days |
1069
- | `prune_batch_size` | 1,000 |
1070
- | `worker_count` | 1 |
1071
- | `effect_worker_count` | 1 |
1072
- | `broadcast_worker_count` | 1 |
1073
- | `reminder_scheduler_count` | 1 |
1074
-
1075
- Payload, state, and result limits; retry delay; table prefix; logging; wake-up;
1076
- broadcast; database; and authorization adapters are also configurable. Invalid
1077
- lease intervals, component counts, and size limits fail fast at boot.
1078
-
1079
- `polling_interval` is the fast interval after work or a wake-up. Consecutive
1080
- empty passes double it up to `idle_polling_interval`. Actor workers never wait
1081
- longer than `lease_renewal_interval`. Set the fast and idle values equal for a
1082
- fixed cadence. The default wake-up reaches only the current Ruby process;
1083
- configure PostgreSQL notifications or optional Redis Pub/Sub when separate
1084
- processes need low-latency delivery. The runtime warns once when it sees that
1085
- topology without an adapter.
1086
-
1087
461
  ## Workers and operations
1088
462
 
1089
463
  `solid_objects start` runs actor, effect, reminder, and broadcast roles under
1090
- one supervisor:
464
+ one supervisor. Administration commands require the administration policy:
1091
465
 
1092
466
  ```bash
1093
467
  bundle exec solid_objects start
1094
- ```
1095
-
1096
- Worker and outbox counts can be overridden:
1097
-
1098
- ```bash
1099
- bundle exec solid_objects start \
1100
- --workers 4 \
1101
- --effect-workers 2 \
1102
- --broadcast-workers 2 \
1103
- --reminder-schedulers 1
1104
- ```
1105
-
1106
- Administration commands require the administration policy:
1107
-
1108
- ```bash
1109
468
  bundle exec solid_objects status
1110
- bundle exec solid_objects cleanup
1111
469
  bundle exec solid_objects prune_messages
1112
- bundle exec solid_objects prune_instances
1113
- bundle exec solid_objects prune_processes
1114
- bundle exec solid_objects dead_letters
1115
470
  bundle exec solid_objects retry_dead_letter 123
1116
471
  ```
1117
472
 
1118
- The prune commands preview counts by default. Add `--execute` only after
1119
- reviewing the configured retention policy.
1120
-
1121
- The supervisor stops new claims, drains active loops, releases cached leases,
1122
- and marks process rows stopped on graceful shutdown. A hard-killed worker's
1123
- claimed turn is recovered after its process heartbeat or activation lease
1124
- becomes stale.
1125
-
1126
- The engine loads actors from the host application's `app/actors` directories
1127
- through Rails' main autoloader, in every process that boots the application.
1128
- This works when eager loading is disabled and does not require actor
1129
- references in an initializer. A web process therefore resolves an actor by
1130
- name for a Cable subscription or a component render without having loaded that
1131
- class through an earlier request.
1132
-
1133
- See the [operations guide](docs/operations.md) for monitoring, reconciliation,
1134
- shutdown, retention, and backup guidance.
473
+ Prune commands preview counts by default; add `--execute` after reviewing the
474
+ retention policy. Graceful shutdown stops new claims, drains active loops,
475
+ releases cached leases, and marks process rows stopped. A hard-killed worker's
476
+ claimed turn is recovered once its heartbeat or lease goes stale. The engine
477
+ loads `app/actors` in every process that boots the application, so a web
478
+ process can resolve an actor by name for a Cable subscription or a component
479
+ render. See the [operations guide](docs/operations.md).
1135
480
 
1136
481
  ## Dashboard
1137
482
 
1138
- `SolidObjects::Web` is a Rack application that shows instances and their state,
1139
- the mailbox, reminders, effects, broadcasts, dead letters, and the registered
1140
- processes. Mount it inside the application routes, so the Rails session
1141
- middleware runs first:
483
+ `SolidObjects::Web` is a Rack application showing instances and their state,
484
+ the mailbox, reminders, effects, broadcasts, dead letters, and processes. Mount
485
+ it inside the application routes so the Rails session middleware runs first:
1142
486
 
1143
487
  ```ruby
1144
- # config/routes.rb
1145
488
  require "solid_objects/web"
1146
489
 
1147
- Rails.application.routes.draw do
1148
- mount SolidObjects::Web => "/solid_objects/dashboard"
1149
- end
1150
- ```
1151
-
1152
- It is not loaded by `require "solid_objects"`: a worker process must not carry
1153
- a web stack. The dashboard and the engine are separate mounts, so an
1154
- application that uses reactive ERB mounts both on different paths.
1155
-
1156
- Every page asks `authorize_administration` before its handler runs, and that
1157
- policy denies by default, so a mount alone exposes nothing. The block receives
1158
- the route's own `action:` and `resource:`, and an `authorization_context:` that
1159
- answers `request`, `session`, and `env`.
1160
-
1161
- The dashboard changes only two things. Retrying a dead letter goes through
1162
- `SolidObjects.dead_letters.retry`, which is idempotent. Pausing an instance
1163
- sets `paused_at` so the activation manager stops claiming that identity; a pass
1164
- already in flight finishes its turn, and a synchronous caller waiting on a
1165
- paused instance times out rather than receiving a result.
1166
-
1167
- The dashboard draws instances per actor type, mailbox depth, and outbox status
1168
- with Chart.js, loaded from a CDN with a subresource integrity hash. A
1169
- deployment with no outbound network access can vendor the file, or turn the
1170
- charts off:
1171
-
1172
- ```ruby
1173
- SolidObjects::Web.chart_library_url = "/javascripts/chart.umd.min.js"
1174
- SolidObjects::Web.chart_library_integrity = nil
490
+ mount SolidObjects::Web => "/solid_objects/dashboard"
1175
491
  ```
1176
492
 
1177
- Read the [dashboard guide](docs/dashboard.md) for the full policy table,
1178
- extension registration, and query cost.
493
+ It is not loaded by `require "solid_objects"`, because a worker process must
494
+ not carry a web stack. Every page asks `authorize_administration` first, and
495
+ that policy denies by default, so a mount alone exposes nothing. See the
496
+ [dashboard guide](docs/dashboard.md).
1179
497
 
1180
498
  ## Database support
1181
499
 
1182
- Solid Objects supports:
1183
-
1184
- - PostgreSQL 14 or newer
1185
- - MySQL 8.0 or newer using InnoDB, through either the `mysql2` or `trilogy`
1186
- client
1187
- - SQLite 3.35 or newer
1188
-
1189
- PostgreSQL and MySQL use `FOR UPDATE SKIP LOCKED` when claiming hot-table rows.
1190
- SQLite uses its serialized writer behavior. All three adapters run the same
1191
- locking, fencing, mailbox, outbox, and engine integration test suite.
500
+ PostgreSQL 14 or newer, MySQL 8.0 or newer on InnoDB through either the
501
+ `mysql2` or `trilogy` client, and SQLite 3.35 or newer. PostgreSQL and MySQL
502
+ claim hot-table rows with `FOR UPDATE SKIP LOCKED`; SQLite uses its serialized
503
+ writer. All three run the same locking, fencing, mailbox, outbox, and engine
504
+ test suite, and no Redis or Kafka service is required.
1192
505
 
1193
- No Redis or Kafka service is required.
1194
-
1195
- By default, actor tables use the application's Active Record connection. A
1196
- separate database role is optional:
506
+ Actor tables use the application's Active Record connection by default. A
507
+ separate database role is optional, and every table participating in an actor
508
+ commit must share one database:
1197
509
 
1198
510
  ```ruby
1199
- SolidObjects.configure do |configuration|
1200
- configuration.connects_to = {
1201
- database: {
1202
- writing: :actors,
1203
- reading: :actors
1204
- }
1205
- }
1206
- end
511
+ configuration.connects_to = { database: { writing: :actors, reading: :actors } }
1207
512
  ```
1208
513
 
1209
- Every table participating in an actor commit must share one database.
1210
-
1211
- Completed message history lives in `solid_objects_messages`, while ready and
1212
- claimed work lives in small membership tables. Polling indexes stay
1213
- proportional to live work, and no partial indexes are required.
1214
-
1215
514
  ## Guarantees
1216
515
 
1217
- For one actor identity, messages are:
1218
-
1219
- - durably enqueued with explicit sequence numbers;
1220
- - processed sequentially in sequence order;
1221
- - delivered at least once; and
1222
- - committed by at most one valid activation owner and fencing generation.
1223
-
1224
- Different actor identities may execute concurrently.
1225
-
1226
- Actor destruction is authorized, synchronous, and linearized by the instance
1227
- row lock. It removes the current incarnation and all actor-owned durable rows.
1228
- A later message may create a fresh incarnation of the same logical identity.
1229
-
1230
- The following writes are atomic for one successful turn:
1231
-
1232
- - actor state and state version;
1233
- - message result and completion;
1234
- - effect outbox entries;
1235
- - reminder changes;
1236
- - actor-to-actor messages; and
1237
- - observable broadcast entries.
516
+ For one actor identity, messages are durably enqueued with explicit sequence
517
+ numbers, processed sequentially in that order, delivered at least once, and
518
+ committed by at most one valid activation owner and fencing generation.
519
+ Different identities may execute concurrently. One successful turn commits
520
+ actor state and version, the message result and completion, effect outbox
521
+ entries, reminder changes, actor-to-actor messages, and observable broadcasts
522
+ together, or none of them.
1238
523
 
1239
524
  Solid Objects does not promise:
1240
525
 
1241
526
  - exactly-once handler or effect execution;
1242
- - global order across actors;
1243
- - distributed transactions;
527
+ - global order across actors, or distributed transactions;
1244
528
  - bounded end-to-end latency;
1245
529
  - cancellation when a synchronous caller times out; or
1246
- - that a lease prevents stale Ruby code from continuing to run.
1247
-
1248
- The fencing generation prevents stale code from committing.
1249
-
1250
- Read [Correctness and delivery semantics](docs/correctness.md) for the full
1251
- contract and crash matrix.
1252
-
1253
- ## When to use it
1254
-
1255
- Solid Objects fits the same coordination-heavy domains that lead developers to
1256
- Cloudflare Durable Objects, when the application belongs in Rails and its
1257
- existing database:
530
+ - that a lease stops stale Ruby code from running.
1258
531
 
1259
- - shopping carts;
1260
- - chat rooms and presence;
1261
- - device twins;
1262
- - user-specific schedules;
1263
- - long-lived workflows;
1264
- - collaborative sessions; and
1265
- - game rooms.
1266
-
1267
- Do not use it for stateless work, bulk pipelines, CPU-heavy computation,
1268
- cross-actor transactions, slow network calls inside handlers, or domains that
1269
- are clearer as normalized Active Record models and direct service objects.
1270
-
1271
- High-QPS request reads, rate-limit counters, impression pipelines, large JSON
1272
- documents, and latency budgets that cannot tolerate several coordination
1273
- transactions are explicit anti-patterns. Read the full
1274
- [fit and anti-pattern guide](docs/fit.md) before migrating an existing
1275
- surface, and use the [legacy-state migration cookbook](docs/migrating-existing-state.md)
1276
- for staged cutovers.
532
+ The fencing generation is what stops stale code from committing. Read
533
+ [correctness](docs/correctness.md) for the full contract.
1277
534
 
1278
535
  ## Comparisons
1279
536
 
1280
537
  | Tool | What Solid Objects adds or changes |
1281
538
  | --- | --- |
1282
- | Cloudflare Durable Objects | Solid Objects ports the named, stateful, serialized-object model to Ruby and Rails. It uses your SQL database and Rails workers rather than Cloudflare's globally distributed serverless runtime, placement, and storage APIs. |
1283
- | Active Job | Jobs are independent work units. Solid Objects adds addressable identity, durable state, explicit per-identity order, activation leases, and fencing. |
1284
- | Solid Queue | Solid Queue is a database backend for Active Job. Its concurrency controls cap overlap but do not guarantee order. Solid Objects provides actor mailboxes, state, fencing, per-identity reminders, and state-driven views. |
1285
- | Action Cable | Cable transports transient realtime messages. Solid Objects owns durable state and work; Cable is an optional delivery path for committed observable projections. |
1286
- | Orleans | Orleans provides the virtual-actor lineage behind the model, with grains, reminders, and activation lifecycle. Solid Objects is a smaller Rails-native runtime and does not match Orleans clustering or placement breadth. |
1287
- | Active Record service object | A service object runs directly against records. Solid Objects adds durable asynchronous ordering, retries, activation fencing, reminders, and outboxes at greater operational cost. |
539
+ | `with_lock` or `SELECT ... FOR UPDATE` | A lock serializes writers for one transaction, on one connection, in one process, and needs nothing installed. Solid Objects covers the part that outlives the transaction: an alarm that fires later, work that survives a restart, and a broadcast that commits with the state change. Prefer the lock when the whole job fits inside one request. |
540
+ | Cloudflare Durable Objects | The same named, stateful, serialized-object model, on your SQL database and Rails workers rather than Cloudflare's globally distributed runtime, placement, and storage APIs. |
541
+ | Active Job and Solid Queue | Jobs are independent work units, and Solid Queue's concurrency controls cap overlap without guaranteeing order. Solid Objects adds addressable identity, durable state, per-identity order, activation leases, and fencing. |
542
+ | Action Cable | Cable transports transient realtime messages. Solid Objects owns the durable state and work; Cable is an optional delivery path for committed observable projections. |
543
+ | Orleans | The virtual-actor lineage behind the model. Solid Objects is a smaller Rails-native runtime and does not match Orleans clustering or placement breadth. |
544
+ | Active Record service object | A service object runs directly against records. Solid Objects adds durable ordering, retries, fencing, reminders, and outboxes at greater operational cost. |
1288
545
 
1289
- ## Development
546
+ ## Development and contributing
1290
547
 
1291
548
  Solid Objects uses Minitest and follows Solid Queue's test organization and
1292
- RuboCop policy. Ruby source carries inline RBS annotations.
1293
-
1294
- Run the full SQLite suite and static checks:
1295
-
1296
- ```bash
1297
- bundle install
1298
- bundle exec rake
1299
- ```
1300
-
1301
- Run the database integration suite against PostgreSQL or MySQL:
1302
-
1303
- ```bash
1304
- SOLID_OBJECTS_DATABASE_URL=postgresql://localhost/solid_objects_test \
1305
- bundle exec rake test
1306
-
1307
- SOLID_OBJECTS_DATABASE_URL=mysql2://localhost/solid_objects_test \
1308
- bundle exec rake test
1309
- ```
1310
-
1311
- Concurrency tests use real database locks and deterministic synchronization,
1312
- not mocked locking behavior.
1313
-
1314
- See the [development guide](docs/development.md) and
1315
- [local benchmarks](docs/benchmarks.md).
549
+ RuboCop policy. Ruby source carries inline RBS annotations, and concurrency
550
+ tests use real database locks rather than mocked locking. `bundle exec rake`
551
+ runs the SQLite suite and static checks; set `SOLID_OBJECTS_DATABASE_URL` for
552
+ PostgreSQL or MySQL.
553
+
554
+ A change here can affect durable state and recovery, so start with a failing
555
+ test and quote the observed failure in the pull request:
556
+
557
+ - [Contributing](CONTRIBUTING.md) covers setup, the quality gates, and what a
558
+ correctness change must show.
559
+ - [Development guide](docs/development.md) covers the test layout and the
560
+ adapter matrix.
561
+ - [Changelog](CHANGELOG.md) and the
562
+ [roadmap](docs/roadmap.md) record what shipped and what is still open.
563
+ - Report a vulnerability through
564
+ [GitHub security advisories](https://github.com/cardmagic/solid-objects-ruby/security/advisories/new)
565
+ rather than a public issue.
1316
566
 
1317
567
  ## Status
1318
568
 
1319
- Implemented and tested in 0.4:
1320
-
1321
- - Rails engine, install generator, migrations, and `solid_objects` executable;
1322
- - actor registry, references, JSON state, and state migrations;
1323
- - direct synchronous actor RPC, explicit `sync`, and durable `async`;
1324
- - guarded transaction boundaries, same-database commit actions, adapter lock
1325
- deadlines, structured synchronous timeout diagnostics, and result recovery;
1326
- - durable message history plus ready and claimed membership tables;
1327
- - concurrent sequence allocation and actor creation;
1328
- - activation leases, per-activation tokens, fencing generations, and
1329
- stale-write rejection;
1330
- - bounded activation passes, idle activation cache, and hot-actor fairness;
1331
- - retries, terminal domain rejection, strict poison ordering, dead letters,
1332
- and retry tooling;
1333
- - transactional effects and asynchronous actor-to-actor messages;
1334
- - one-shot and recurring per-actor reminders;
1335
- - authorized actor destruction with fenced stale-write rejection and cascading
1336
- durable-work cleanup;
1337
- - durable observable invalidations, scalar Turbo replacement, and authorized
1338
- request-time ERB component refresh;
1339
- - process registration, heartbeats, caller shutdown, cleanup, and bounded
1340
- message/process retention plus opt-in actor-instance expiration;
1341
- - an opt-in Minitest helper for actor-state isolation and deterministic async
1342
- actor/reminder/effect/broadcast draining;
1343
- - authorized mailbox-free state snapshots and mutable JSON copies; and
1344
- - SQLite, PostgreSQL, and MySQL integration tests.
1345
-
1346
- Partially implemented:
1347
-
1348
- - the supervisor starts and drains roles but does not replace a crashed role or
1349
- run periodic maintenance automatically;
1350
- - PostgreSQL notifications and optional Redis acceleration are implemented,
1351
- but adapter selection remains explicit and polling is the durable fallback;
1352
- - live observable and component replacement work, while Turbo append actions
1353
- remain future work;
1354
- - local admission limits exist, but distributed rate limits and global
1355
- admission control do not; and
1356
- - administration views and pruning commands exist, but scheduled maintenance
1357
- and richer audit tools do not.
1358
-
1359
- Production readiness requires hardening and operational soak evidence. The
1360
- [roadmap](docs/roadmap.md) tracks that work.
569
+ The correctness core is implemented and tested against SQLite, PostgreSQL, and
570
+ MySQL: ordered mailboxes, fenced activation, retries and dead letters,
571
+ reminders, effects, commit actions, destruction, and reactive views. Still
572
+ open: Turbo append actions, distributed rate limits and global admission
573
+ control, and scheduled maintenance beyond the pruning commands.
574
+
575
+ There is no production-ready claim; that needs hardening and operational soak
576
+ evidence. The [roadmap](docs/roadmap.md) tracks what is done, what is partial,
577
+ and what is measured rather than assumed.
1361
578
 
1362
579
  ## License
1363
580