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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +53 -0
- data/README.md +121 -522
- data/benchmark/state_size.rb +5 -0
- data/benchmark/support.rb +63 -0
- data/docs/adr/0006-at-least-once-delivery.md +1 -1
- data/docs/architecture.md +7 -6
- data/docs/authorization.md +4 -4
- data/docs/benchmarks.md +48 -3
- data/docs/correctness.md +2 -2
- data/docs/fit.md +24 -7
- data/docs/local-testing.md +3 -3
- data/docs/migrating-existing-state.md +1 -1
- data/docs/operations.md +32 -4
- data/docs/realtime.md +2 -2
- data/docs/reminders.md +6 -6
- data/docs/research/solid_queue.md +1 -1
- data/docs/roadmap.md +9 -1
- data/lib/solid_objects/configuration.rb +7 -0
- data/lib/solid_objects/executor.rb +37 -16
- data/lib/solid_objects/instrumentation.rb +33 -0
- data/lib/solid_objects/serialization.rb +18 -5
- data/lib/solid_objects/version.rb +1 -1
- data/sig/generated/lib/solid_objects/configuration.rbs +7 -3
- data/sig/generated/lib/solid_objects/executor.rbs +7 -4
- data/sig/generated/lib/solid_objects/instrumentation.rbs +11 -0
- data/sig/generated/lib/solid_objects/serialization.rbs +16 -0
- metadata +3 -2
data/README.md
CHANGED
|
@@ -1,240 +1,44 @@
|
|
|
1
|
-
# Solid Objects
|
|
1
|
+
# Solid Objects for Rails
|
|
2
2
|
|
|
3
|
-
[](https://github.com/cardmagic/solid-objects-ruby/actions/workflows/ci.yml)
|
|
3
|
+
[](https://github.com/cardmagic/solid-objects-ruby/actions/workflows/ci.yml)
|
|
4
|
+
[](https://rubygems.org/gems/solid_objects)
|
|
4
5
|
|
|
5
|
-
**
|
|
6
|
+
**Open Source Durable Objects in your Rails app.**
|
|
6
7
|
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
- [
|
|
93
|
-
- [
|
|
94
|
-
- [
|
|
95
|
-
- [
|
|
96
|
-
- [
|
|
97
|
-
- [
|
|
98
|
-
- [
|
|
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.
|
|
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
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
319
|
-
|
|
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
|
-
|
|
58
|
+
SolidObjects.configure do |configuration|
|
|
59
|
+
configuration.authorize_message = ->(**) { true }
|
|
60
|
+
configuration.authorize_query = ->(**) { true }
|
|
61
|
+
end
|
|
339
62
|
```
|
|
340
63
|
|
|
341
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
end
|
|
372
|
-
```
|
|
72
|
+
class TicketSale < SolidObjects::Actor
|
|
73
|
+
attribute :available, default: 1
|
|
74
|
+
attribute :holds, default: -> { {} }
|
|
373
75
|
|
|
374
|
-
|
|
375
|
-
|
|
76
|
+
def hold(buyer:)
|
|
77
|
+
return { held: false, available: } if available.zero? || holds.key?(buyer)
|
|
376
78
|
|
|
377
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
completion, and an effect worker performs the call afterwards:
|
|
85
|
+
def expire(buyer:)
|
|
86
|
+
return available unless holds.key?(buyer)
|
|
382
87
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
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
|
-
|
|
404
|
-
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
-
|
|
436
|
-
|
|
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
|
-
|
|
439
|
-
state["currency"] ||= "USD"
|
|
440
|
-
state
|
|
441
|
-
end
|
|
442
|
-
```
|
|
116
|
+
## Why this exists
|
|
443
117
|
|
|
444
|
-
|
|
445
|
-
|
|
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
|
-
|
|
123
|
+
This is complicated, hard to test, fragile and unnecessary.
|
|
448
124
|
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
464
|
-
one
|
|
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
|
-
|
|
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
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
488
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
-
|
|
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
|
-
|
|
511
|
-
|
|
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
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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.
|