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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +83 -0
- data/README.md +115 -1299
- 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 +46 -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 +178 -24
- data/docs/realtime.md +2 -2
- data/docs/reminders.md +112 -0
- 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 +4 -2
data/README.md
CHANGED
|
@@ -1,350 +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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
- [
|
|
78
|
-
- [
|
|
79
|
-
- [
|
|
80
|
-
- [
|
|
81
|
-
- [
|
|
82
|
-
- [
|
|
83
|
-
- [
|
|
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.
|
|
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
|
|
357
|
-
authorization
|
|
358
|
-
|
|
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
|
-
|
|
454
|
-
|
|
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.
|
|
490
|
-
|
|
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
|
-
|
|
723
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
738
|
-
|
|
739
|
-
|
|
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
|
-
|
|
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
|
-
|
|
756
|
-
|
|
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
|
-
|
|
948
|
-
|
|
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
|
-
|
|
985
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
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
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
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
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
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
|
-
|
|
116
|
+
## Why this exists
|
|
1210
117
|
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
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
|
-
|
|
123
|
+
This is complicated, hard to test, fragile and unnecessary.
|
|
1216
124
|
|
|
1217
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1227
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1312
|
-
|
|
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
|
-
|
|
1315
|
-
|
|
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
|
-
##
|
|
149
|
+
## Guarantees and boundaries
|
|
1318
150
|
|
|
1319
|
-
|
|
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
|
-
|
|
1322
|
-
|
|
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
|
-
|
|
163
|
+
## Read more
|
|
1347
164
|
|
|
1348
|
-
-
|
|
1349
|
-
|
|
1350
|
-
-
|
|
1351
|
-
|
|
1352
|
-
-
|
|
1353
|
-
|
|
1354
|
-
-
|
|
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
|
-
|
|
1360
|
-
|
|
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
|
-
##
|
|
177
|
+
## Status and license
|
|
1363
178
|
|
|
1364
|
-
Solid Objects is
|
|
1365
|
-
|
|
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
|
|
1368
|
-
sponsored by, or endorsed by
|
|
1369
|
-
|
|
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.
|