solid_objects 0.14.1 → 0.14.3

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
@@ -1,350 +1,44 @@
1
- # Solid Objects
1
+ # Solid Objects for Rails
2
2
 
3
- [![CI](https://github.com/cardmagic/solid-objects-ruby/actions/workflows/ci.yml/badge.svg)](https://github.com/cardmagic/solid-objects-ruby/actions/workflows/ci.yml)
3
+ [![CI](https://github.com/cardmagic/solid-objects-ruby/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/cardmagic/solid-objects-ruby/actions/workflows/ci.yml)
4
+ [![gem](https://img.shields.io/gem/v/solid_objects)](https://rubygems.org/gems/solid_objects)
4
5
 
5
- **Self-hosted, distributed Durable Objects in Rails without a daemon using your existing SQL database.**
6
+ **Open Source Durable Objects in your Rails app.**
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
+ In a shopping cart, paying twice at the same time is a big problem. The payment provider might time out, and your Rails site could be restarting before recovery finishes.
13
9
 
14
- ```ruby
15
- class Counter < SolidObjects::Actor
16
- attribute :value, default: 0
17
-
18
- def increment(amount: 1)
19
- self.value += amount
20
- end
21
- end
22
-
23
- # 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)
31
- ```
32
-
33
- `Counter / global` is a logical identity. Like a Durable Object named with
34
- `idFromName`, it can be addressed from anywhere without first creating or
35
- locating a Ruby object. Solid Objects activates it when work arrives, commits
36
- its ordered turns one at a time, persists its state, and deactivates it when
37
- idle. Different identities can run concurrently.
38
-
39
- The invocation model is the first adoption decision:
10
+ To deal with this safely, you often need logic scattered between 7-10 files like database row locks, Redis locks, delayed jobs, retries, and cleanup code to keep that process straight. They are not all large, but they must agree about the same payment state and failure rules. That coordination is the difficult part.
40
11
 
41
- | Call | Returns | Worker fleet required? |
42
- | --- | --- | --- |
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 |
12
+ With Solid Objects, one actor in one file owns each shopping cart's full state and recovery work. Method calls on that object run one at a time, state lives in your existing SQL database, and scheduled recovery resume after restarts.
48
13
 
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.
14
+ Solid Object Rails Actors elegantly fit anything where one identifiable thing must remember state, handle competing requests in order, or wake up later:
52
15
 
53
- Synchronous calls fail before enqueue when the Solid Objects database
54
- connection is already inside a transaction. Actor handlers may read application
55
- records, but direct Active Record writes are rejected so they cannot escape a
56
- later actor failure. Use a same-database
57
- [`commit_action`](#application-database-writes) for atomic database changes and
58
- [`emit`](#effects) for external I/O.
16
+ - Ticket holds and reservations
17
+ - Multiplayer games and shared rooms
18
+ - Shopping carts and checkout recovery
19
+ - Rate limits and account quotas
20
+ - Session expiration
21
+ - Job leases and workflows
22
+ - Connected devices
23
+ - Collaborative documents
59
24
 
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).
25
+ And so much more.
63
26
 
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
27
 
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).
28
+ ## Contents
71
29
 
72
- ## Table of contents
73
-
74
- - [Cloudflare Durable Objects for Rails](#cloudflare-durable-objects-for-rails)
75
- - [Reactive ERB](#reactive-erb)
76
30
  - [Installation](#installation)
77
- - [Upgrading](#upgrading)
78
- - [Worker requirements](#worker-requirements)
79
- - [Defining an actor](#defining-an-actor)
80
- - [Actor identity](#actor-identity)
81
- - [Invoking an object](#invoking-an-object)
82
- - [Application database writes](#application-database-writes)
83
- - [Effects](#effects)
84
- - [Reminders](#reminders)
85
- - [Destroying an object](#destroying-an-object)
86
- - [State migrations](#state-migrations)
87
- - [Configuration](#configuration)
88
- - [Workers and operations](#workers-and-operations)
89
- - [Dashboard](#dashboard)
90
- - [Database support](#database-support)
91
- - [Guarantees](#guarantees)
92
- - [When to use it](#when-to-use-it)
93
- - [Comparisons](#comparisons)
94
- - [Development](#development)
95
- - [Status](#status)
96
- - [License](#license)
97
-
98
- ## Cloudflare Durable Objects for Rails
99
-
100
- Cloudflare Durable Objects combine a name, durable storage, serialized
101
- execution, alarms, and live connections in one stateful object. Solid Objects
102
- maps those ideas into Rails:
103
-
104
- | Cloudflare Durable Objects | Solid Objects |
105
- | --- | --- |
106
- | Namespace plus `idFromName("id")` | Actor class plus `.ref("id")` |
107
- | RPC method on a stub | Public Ruby method on a reference |
108
- | Per-object transactional storage | Declared attributes in native JSON |
109
- | Single-threaded input handling | Ordered mailbox plus fenced activation |
110
- | Alarms API | Per-object `schedule` |
111
- | WebSockets | Reactive ERB over Action Cable and Turbo Streams |
112
- | Hibernation when idle | Idle activation deactivation |
113
- | Storage deletion | Authorized `reference.destroy` |
114
- | Cloudflare Workers platform | Your Rails processes and SQL database |
115
-
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
128
- [`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.
172
-
173
- ## Reactive ERB
174
-
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:
192
-
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
- ```
219
-
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:
229
-
230
- ```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 %>
239
- <% end %>
240
- ```
241
-
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.
257
-
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
- ```
31
+ - [An expiring ticket hold](#an-expiring-ticket-hold)
32
+ - [Why this exists](#why-this-exists)
33
+ - [Good uses](#good-uses)
34
+ - [When a transaction is better](#when-a-transaction-is-better)
35
+ - [Guarantees and boundaries](#guarantees-and-boundaries)
36
+ - [Read more](#read-more)
37
+ - [Status and license](#status-and-license)
341
38
 
342
39
  ## Installation
343
40
 
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:
41
+ Solid Objects requires Ruby 3.3 or newer and Rails 7.1 or newer.
348
42
 
349
43
  ```bash
350
44
  bundle add solid_objects
@@ -353,1018 +47,140 @@ bin/rails db:migrate
353
47
  bin/rails solid_objects:doctor
354
48
  ```
355
49
 
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.
361
-
362
- 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.
450
-
451
- ## Worker requirements
50
+ The generator adds Solid Objects tables to the application's existing database.
51
+ All authorization policies deny by default, a rare example of generated code
52
+ declining to become an incident.
452
53
 
453
- Synchronous actors can be adopted without adding a long-running process. Start
454
- the runtime when the feature introduces asynchronous delivery or outboxes:
455
-
456
- | Feature | Runtime roles required |
457
- | --- | --- |
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 |
462
- | `async` including delayed delivery | Actor worker |
463
- | 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 |
466
- | Actor-to-actor `async` or `send_to` | Effect worker and actor worker |
467
- | 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
-
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:
54
+ For the local example below, allow messages and queries in the generated
55
+ initializer:
486
56
 
487
57
  ```ruby
488
58
  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.
512
-
513
- ## Defining an actor
514
-
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`.
563
-
564
- 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:
570
-
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
587
-
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.
623
-
624
- ## Invoking an object
625
-
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.
652
-
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:
672
-
673
- ```ruby
674
- order.async(available_at: 10.minutes.from_now).evaluate
675
- ```
676
-
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
- ```
688
-
689
- 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
- )
59
+ configuration.authorize_message = ->(**) { true }
60
+ configuration.authorize_query = ->(**) { true }
719
61
  end
720
62
  ```
721
63
 
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.
64
+ Those callbacks are for local testing only. Production policies must bind actor
65
+ IDs and operations to the authenticated user or tenant.
725
66
 
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.
67
+ ## An expiring ticket hold
731
68
 
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.
69
+ Put this ordinary Ruby class in `app/actors/ticket_sale.rb`:
735
70
 
736
71
  ```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.
72
+ class TicketSale < SolidObjects::Actor
73
+ attribute :available, default: 1
74
+ attribute :holds, default: -> { {} }
746
75
 
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)
76
+ def hold(buyer:)
77
+ return { held: false, available: } if available.zero? || holds.key?(buyer)
754
78
 
755
- self.response = response
756
- end
757
- ```
758
-
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.
762
-
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.
766
-
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.
782
-
783
- ## Application database writes
784
-
785
- Actor handlers execute outside the fenced commit. They may query application
786
- 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.
790
-
791
- For a short database-only change that must commit atomically with actor state,
792
- stage a named action:
793
-
794
- ```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:
79
+ self.available -= 1
80
+ self.holds = holds.merge(buyer => Time.current.to_i)
81
+ schedule(at: 10.minutes.from_now, key: buyer).expire(buyer:)
82
+ { held: true, available: }
801
83
  end
802
- end
803
- ```
804
-
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.
826
-
827
- ## Effects
828
-
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:
832
-
833
- ```ruby
834
- def checkout(payment_id:, amount_cents:)
835
- return unless checkout_status == "open"
836
-
837
- 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
- )
865
- end
866
- ```
867
-
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.
874
-
875
- ## Reminders
876
-
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:
946
84
 
947
- ```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
969
- ```
970
-
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.
85
+ def expire(buyer:)
86
+ return available unless holds.key?(buyer)
983
87
 
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.
987
-
988
- ## Destroying an object
989
-
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.
1013
-
1014
- ## State migrations
1015
-
1016
- Actor state has an independent schema version:
1017
-
1018
- ```ruby
1019
- class ShoppingCart < SolidObjects::Actor
1020
- state_version 2
1021
-
1022
- migrate_state from: 1, to: 2 do |state|
1023
- state["currency"] ||= "USD"
1024
- state
88
+ self.holds = holds.except(buyer)
89
+ self.available += 1
1025
90
  end
1026
91
  end
1027
92
  ```
1028
93
 
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.
1034
-
1035
- ## Configuration
1036
-
1037
- Configure Solid Objects in `config/initializers/solid_objects.rb`:
94
+ Call it from a controller, job, console, or anywhere else in the Rails app:
1038
95
 
1039
96
  ```ruby
1040
- SolidObjects.configure do |configuration|
1041
- configuration.worker_count = 4
1042
- 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
- end
97
+ result = TicketSale.ref(params.require(:event_id)).hold(
98
+ buyer: current_user.id.to_s
99
+ )
100
+ render json: result
1047
101
  ```
1048
102
 
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
- ## Workers and operations
1088
-
1089
- `solid_objects start` runs actor, effect, reminder, and broadcast roles under
1090
- one supervisor:
103
+ Concurrent requests for the same event enter the same durable mailbox and
104
+ commit one at a time. The successful call stores the hold and its ten-minute
105
+ reminder with the state change. The direct call needs no worker; the reminder
106
+ does:
1091
107
 
1092
108
  ```bash
1093
109
  bundle exec solid_objects start
1094
110
  ```
1095
111
 
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
- bundle exec solid_objects status
1110
- bundle exec solid_objects cleanup
1111
- 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
- bundle exec solid_objects retry_dead_letter 123
1116
- ```
1117
-
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.
1135
-
1136
- ## Dashboard
1137
-
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:
1142
-
1143
- ```ruby
1144
- # config/routes.rb
1145
- require "solid_objects/web"
1146
-
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
1175
- ```
1176
-
1177
- Read the [dashboard guide](docs/dashboard.md) for the full policy table,
1178
- extension registration, and query cost.
1179
-
1180
- ## Database support
1181
-
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.
1192
-
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:
1197
-
1198
- ```ruby
1199
- SolidObjects.configure do |configuration|
1200
- configuration.connects_to = {
1201
- database: {
1202
- writing: :actors,
1203
- reading: :actors
1204
- }
1205
- }
1206
- end
1207
- ```
112
+ Stop that process before the deadline and restart it afterwards. The reminder
113
+ is still in the Rails database and runs when the process returns. We have given
114
+ `self.available += 1` a supervisor and excellent posture.
1208
115
 
1209
- Every table participating in an actor commit must share one database.
116
+ ## Why this exists
1210
117
 
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.
118
+ The handwritten Rails version usually starts with `with_lock`. Then it gains an
119
+ `expires_at` column, a cron job, an Active Job retry policy, and an Action Cable
120
+ broadcast that must agree with the write. A small invariant has become a rich
121
+ tapestry of callbacks and scheduled cleanup.
1214
122
 
1215
- ## Guarantees
123
+ This is complicated, hard to test, fragile and unnecessary.
1216
124
 
1217
- For one actor identity, messages are:
125
+ Solid Objects keeps the identity, state, ordered calls, retries, reminders, and
126
+ staged consequences together inside the Rails application. It uses SQLite,
127
+ PostgreSQL, or MySQL. Redis and a separate actor service are not required.
1218
128
 
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.
129
+ ## Good uses
1223
130
 
1224
- Different actor identities may execute concurrently.
131
+ - Multiplayer rooms, chats, and collaborative sessions with ordered changes.
132
+ - Carts, reservations, and inventory holds with durable expiry.
133
+ - Account, device, assessment, and approval workflows that survive deploys.
134
+ - Reactive ERB views that must follow committed actor revisions.
1225
135
 
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.
136
+ Different identities can run concurrently. Put the whole application behind
137
+ one actor ID and Rails will faithfully operate your new bottleneck.
1229
138
 
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.
1238
-
1239
- Solid Objects does not promise:
1240
-
1241
- - exactly-once handler or effect execution;
1242
- - global order across actors;
1243
- - distributed transactions;
1244
- - bounded end-to-end latency;
1245
- - 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:
1258
-
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.
1277
-
1278
- ## Comparisons
1279
-
1280
- | Tool | What Solid Objects adds or changes |
1281
- | --- | --- |
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. |
1288
-
1289
- ## Development
1290
-
1291
- 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
- ```
139
+ ## When a transaction is better
1310
140
 
1311
- Concurrency tests use real database locks and deterministic synchronization,
1312
- not mocked locking behavior.
141
+ Often. If the entire invariant fits inside one request, use `with_lock`, a
142
+ database constraint, or a short transaction. A row lock does not need a
143
+ personal brand, and it is usually the clearest answer.
1313
144
 
1314
- See the [development guide](docs/development.md) and
1315
- [local benchmarks](docs/benchmarks.md).
145
+ Use Solid Objects when work must happen later, survive a restart, or stay
146
+ ordered across several requests or jobs. A plain counter remains one line of
147
+ SQL and should be allowed to enjoy that.
1316
148
 
1317
- ## Status
149
+ ## Guarantees and boundaries
1318
150
 
1319
- Implemented and tested in 0.4:
151
+ - Calls are durably ordered per identity. Different identities may run concurrently.
152
+ - Delivery is **at least once**, not exactly once. A handler can begin again after a crash or lease loss.
153
+ - One successful turn commits actor state and staged reminders, messages, effects, commit actions, and broadcasts together.
154
+ - Fencing prevents stale Ruby code from committing, but it cannot stop that code from continuing to run.
155
+ - External effects can repeat and must deduplicate with the stable effect ID or another durable idempotency key.
156
+ - Actor handlers may read application records but cannot write them directly. Use `commit_action` for bounded same-database writes and `emit` for external I/O.
157
+ - `async`, reminders, effects, and broadcasts need `bundle exec solid_objects start`. Pending work remains in SQL while it is down.
158
+ - One hot identity is intentionally sequential. There are no transactions across actor identities.
1320
159
 
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.
160
+ Exactly once is not hiding in a more advanced configuration. Read the
161
+ [correctness contract](docs/correctness.md) before using important data.
1345
162
 
1346
- Partially implemented:
163
+ ## Read more
1347
164
 
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.
165
+ - [Five-minute Rails guide](https://solidobjects.dev/5min/rails)
166
+ - [Choosing Solid Objects](docs/fit.md)
167
+ - [Operations and recovery](docs/operations.md)
168
+ - [Reminders](docs/reminders.md)
169
+ - [Reactive ERB](docs/realtime.md)
170
+ - [Detailed architecture](docs/architecture.md)
171
+ - [Detailed documentation](docs/)
1358
172
 
1359
- Production readiness requires hardening and operational soak evidence. The
1360
- [roadmap](docs/roadmap.md) tracks that work.
173
+ The dashboard, benchmarks, migration cookbook, schema, and exhaustive API
174
+ explanations remain in `docs/`. The README is stopping before it develops a
175
+ robust interplay with its own table of contents.
1361
176
 
1362
- ## License
177
+ ## Status and license
1363
178
 
1364
- Solid Objects is MIT Licensed by Lucas Carlson. See
1365
- [MIT-LICENSE](MIT-LICENSE).
179
+ Solid Objects Ruby is a pre-1.0 early release. Its correctness core is tested
180
+ against SQLite, PostgreSQL, and MySQL, but the project makes no production-ready
181
+ claim. That requires more hardening and operational soak evidence. Pre-1.0 is
182
+ not decorative punctuation.
1366
183
 
1367
- Solid Objects is an independent open-source project. It is not affiliated with,
1368
- sponsored by, or endorsed by Cloudflare, Inc. “Cloudflare” and “Durable
1369
- Objects” are trademarks of Cloudflare, Inc. and are used here to identify the
1370
- programming model this gem ports to Rails.
184
+ Solid Objects is released under the [MIT License](MIT-LICENSE). It is an
185
+ independent project and is not affiliated with, sponsored by, or endorsed by
186
+ Cloudflare.