solid_objects 0.14.2 → 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,240 +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, 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.
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.
12
9
 
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).
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.
18
11
 
19
- A ticket sale for one event, with 100 seats and a hold that expires:
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.
20
13
 
21
- ```ruby
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
14
+ Solid Object Rails Actors elegantly fit anything where one identifiable thing must remember state, handle competing requests in order, or wake up later:
36
15
 
37
- def expire(buyer:)
38
- return unless holds.key?(buyer)
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
39
24
 
40
- self.holds = holds.except(buyer)
41
- self.remaining += 1
42
- end
43
- end
25
+ And so much more.
44
26
 
45
- # Synchronous caller-assisted RPC. No worker fleet is required.
46
- sale = TicketSale.ref("event-42")
47
- sale.reserve(buyer: current_user.id)
48
- ```
49
-
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
61
- `idFromName`, it can be addressed from anywhere without first creating or
62
- locating a Ruby object. Solid Objects activates it when work arrives, commits
63
- its ordered turns one at a time, persists its state, and deactivates it when
64
- idle. Different identities run concurrently, so two events never wait on each
65
- other.
66
-
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.
72
-
73
- | Call | Returns | Worker fleet required? |
74
- | --- | --- | --- |
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
82
- [`commit_action`](#application-database-writes) for atomic database changes and
83
- [`emit`](#effects) for external I/O.
84
27
 
85
28
  ## Contents
86
29
 
87
- - [Why not just use transactions?](#why-not-just-use-transactions)
88
- - [Is it worth installing here?](#is-it-worth-installing-here)
89
- - [Cloudflare Durable Objects for Rails](#cloudflare-durable-objects-for-rails)
90
- - [Reactive ERB](#reactive-erb)
91
30
  - [Installation](#installation)
92
- - [Worker requirements](#worker-requirements)
93
- - [Defining an actor](#defining-an-actor)
94
- - [Invoking an object](#invoking-an-object)
95
- - [Application database writes](#application-database-writes)
96
- - [Effects](#effects)
97
- - [Reminders](#reminders)
98
- - [Destroying an object](#destroying-an-object)
99
- - [State migrations](#state-migrations)
100
- - [Configuration](#configuration)
101
- - [Workers and operations](#workers-and-operations)
102
- - [Dashboard](#dashboard)
103
- - [Database support](#database-support)
104
- - [Guarantees](#guarantees)
105
- - [Comparisons](#comparisons)
106
- - [Development and contributing](#development-and-contributing)
107
- - [Status](#status)
108
- - [License](#license)
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
-
166
- ## Cloudflare Durable Objects for Rails
167
-
168
- Cloudflare Durable Objects combine a name, durable storage, serialized
169
- execution, alarms, and live connections in one stateful object. Solid Objects
170
- maps those ideas into Rails:
171
-
172
- | Cloudflare Durable Objects | Solid Objects |
173
- | --- | --- |
174
- | Namespace plus `idFromName("id")` | Actor class plus `.ref("id")` |
175
- | RPC method on a stub | Public Ruby method on a reference |
176
- | Per-object transactional storage | Declared attributes in native JSON |
177
- | Single-threaded input handling | Ordered mailbox plus fenced activation |
178
- | Alarms API | Per-object `schedule` |
179
- | WebSockets | Reactive ERB over Action Cable and Turbo Streams |
180
- | Hibernation when idle | Idle activation deactivation |
181
- | Storage deletion | Authorized `reference.destroy` |
182
- | Cloudflare Workers platform | Your Rails processes and SQL database |
183
-
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
187
- [`limits_concurrency`](https://github.com/rails/solid_queue#concurrency-controls)
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.
192
-
193
- ## Reactive ERB
194
-
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.
197
-
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.
204
-
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.
210
-
211
- ```erb
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 %>
215
- <% end %>
216
- ```
217
-
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.
227
-
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.
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)
233
38
 
234
39
  ## Installation
235
40
 
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.
41
+ Solid Objects requires Ruby 3.3 or newer and Rails 7.1 or newer.
238
42
 
239
43
  ```bash
240
44
  bundle add solid_objects
@@ -243,345 +47,140 @@ bin/rails db:migrate
243
47
  bin/rails solid_objects:doctor
244
48
  ```
245
49
 
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.
249
-
250
- The generated initializer is intentionally inert: all five policies deny by
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).
257
-
258
- ## Worker requirements
259
-
260
- Synchronous actors can be adopted without adding a long-running process. Start
261
- the runtime when the feature introduces asynchronous delivery or outboxes:
262
-
263
- | Feature | Runtime roles required |
264
- | --- | --- |
265
- | Direct actor method, `sync`, query read, `snapshot`, or `destroy` | None; the caller executes it |
266
- | `async` including delayed delivery | Actor worker |
267
- | One-shot or recurring `schedule` | Reminder scheduler and actor worker |
268
- | `emit`, with or without an actor callback | Effect worker, plus actor worker for callbacks |
269
- | Actor-to-actor `async` or `send_to` | Effect worker and actor worker |
270
- | Scalar or component Turbo updates | Broadcast worker, Action Cable, and the actor execution path |
271
-
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).
277
-
278
- ## Defining an actor
279
-
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.
286
-
287
- State, arguments, results, effects, and reminder arguments accept
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.
291
-
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.
295
-
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.
301
-
302
- ## Invoking an object
303
-
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.
308
-
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:
311
-
312
- ```ruby
313
- order.async(idempotency_key: "submit-order-123").submit
314
- order.async(available_at: 10.minutes.from_now).evaluate
315
- order.sync(timeout: 5.seconds, authorization_context: Current.user).status
316
- ```
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.
317
53
 
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.
321
-
322
- Delivery configuration belongs on `async(...)` or `sync(...)` before the
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.
330
-
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:
54
+ For the local example below, allow messages and queries in the generated
55
+ initializer:
336
56
 
337
57
  ```ruby
338
- send_to(audit_log, idempotency_key: event_id).record(event_name: "account_disabled")
58
+ SolidObjects.configure do |configuration|
59
+ configuration.authorize_message = ->(**) { true }
60
+ configuration.authorize_query = ->(**) { true }
61
+ end
339
62
  ```
340
63
 
341
- ### Domain rejection and redelivery
342
-
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`.
64
+ Those callbacks are for local testing only. Production policies must bind actor
65
+ IDs and operations to the authenticated user or tenant.
349
66
 
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).
67
+ ## An expiring ticket hold
354
68
 
355
- ## Application database writes
356
-
357
- Actor handlers execute outside the fenced commit. They may query application
358
- records, but Solid Objects rejects direct Active Record writes from all
359
- user-supplied actor code, so an application row cannot commit before the actor
360
- later raises or loses its fence.
361
-
362
- For a short database-only change that must commit atomically with actor state,
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:
69
+ Put this ordinary Ruby class in `app/actors/ticket_sale.rb`:
366
70
 
367
71
  ```ruby
368
- def finish(attempt_id:, score:)
369
- self.status = "complete"
370
- commit_action :complete_attempt, attempt_id:, score:
371
- end
372
- ```
72
+ class TicketSale < SolidObjects::Actor
73
+ attribute :available, default: 1
74
+ attribute :holds, default: -> { {} }
373
75
 
374
- See
375
- [registering handlers](docs/architecture.md#registering-effect-and-commit-action-handlers).
76
+ def hold(buyer:)
77
+ return { held: false, available: } if available.zero? || holds.key?(buyer)
376
78
 
377
- ## Effects
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: }
83
+ end
378
84
 
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:
85
+ def expire(buyer:)
86
+ return available unless holds.key?(buyer)
382
87
 
383
- ```ruby
384
- def checkout(payment_id:)
385
- self.checkout_status = "pending"
386
- emit(:charge_payment, payment_id:,
387
- on_success: :payment_succeeded, on_failure: :payment_failed)
88
+ self.holds = holds.except(buyer)
89
+ self.available += 1
90
+ end
388
91
  end
389
92
  ```
390
93
 
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).
395
-
396
- ## Reminders
397
-
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:
94
+ Call it from a controller, job, console, or anywhere else in the Rails app:
401
95
 
402
96
  ```ruby
403
- schedule(at: 30.minutes.from_now).expire
404
- schedule(at: entry.fetch("wait_until"), key: entry.fetch("id")).deliver
97
+ result = TicketSale.ref(params.require(:event_id)).hold(
98
+ buyer: current_user.id.to_s
99
+ )
100
+ render json: result
405
101
  ```
406
102
 
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.
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:
412
107
 
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.
416
-
417
- ## Destroying an object
418
-
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.
427
-
428
- ## State migrations
429
-
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:
108
+ ```bash
109
+ bundle exec solid_objects start
110
+ ```
434
111
 
435
- ```ruby
436
- state_version 2
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.
437
115
 
438
- migrate_state from: 1, to: 2 do |state|
439
- state["currency"] ||= "USD"
440
- state
441
- end
442
- ```
116
+ ## Why this exists
443
117
 
444
- Read the [state migration guide](docs/state-migrations.md) before changing
445
- persisted state.
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.
446
122
 
447
- ## Configuration
123
+ This is complicated, hard to test, fragile and unnecessary.
448
124
 
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.
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.
453
128
 
454
- ```ruby
455
- SolidObjects.configure do |configuration|
456
- configuration.worker_count = 4
457
- configuration.lease_duration = 30.seconds
458
- end
459
- ```
129
+ ## Good uses
460
130
 
461
- ## Workers and operations
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.
462
135
 
463
- `solid_objects start` runs actor, effect, reminder, and broadcast roles under
464
- one supervisor. Administration commands require the administration policy:
136
+ Different identities can run concurrently. Put the whole application behind
137
+ one actor ID and Rails will faithfully operate your new bottleneck.
465
138
 
466
- ```bash
467
- bundle exec solid_objects start
468
- bundle exec solid_objects status
469
- bundle exec solid_objects prune_messages
470
- bundle exec solid_objects retry_dead_letter 123
471
- ```
139
+ ## When a transaction is better
472
140
 
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).
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.
480
144
 
481
- ## Dashboard
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.
482
148
 
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:
149
+ ## Guarantees and boundaries
486
150
 
487
- ```ruby
488
- require "solid_objects/web"
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.
489
159
 
490
- mount SolidObjects::Web => "/solid_objects/dashboard"
491
- ```
160
+ Exactly once is not hiding in a more advanced configuration. Read the
161
+ [correctness contract](docs/correctness.md) before using important data.
492
162
 
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).
163
+ ## Read more
497
164
 
498
- ## Database support
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/)
499
172
 
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.
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.
505
176
 
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:
177
+ ## Status and license
509
178
 
510
- ```ruby
511
- configuration.connects_to = { database: { writing: :actors, reading: :actors } }
512
- ```
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.
513
183
 
514
- ## Guarantees
515
-
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.
523
-
524
- Solid Objects does not promise:
525
-
526
- - exactly-once handler or effect execution;
527
- - global order across actors, or distributed transactions;
528
- - bounded end-to-end latency;
529
- - cancellation when a synchronous caller times out; or
530
- - that a lease stops stale Ruby code from running.
531
-
532
- The fencing generation is what stops stale code from committing. Read
533
- [correctness](docs/correctness.md) for the full contract.
534
-
535
- ## Comparisons
536
-
537
- | Tool | What Solid Objects adds or changes |
538
- | --- | --- |
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. |
545
-
546
- ## Development and contributing
547
-
548
- Solid Objects uses Minitest and follows Solid Queue's test organization and
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.
566
-
567
- ## Status
568
-
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.
578
-
579
- ## License
580
-
581
- Solid Objects is MIT Licensed by Lucas Carlson. See
582
- [MIT-LICENSE](MIT-LICENSE).
583
-
584
- Solid Objects is an independent open-source project. It is not affiliated with,
585
- sponsored by, or endorsed by Cloudflare, Inc. “Cloudflare” and “Durable
586
- Objects” are trademarks of Cloudflare, Inc. and are used here to identify the
587
- 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.