solid_objects 0.2.1 → 0.4.0
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 +24 -0
- data/README.md +261 -31
- data/app/controllers/solid_objects/components_controller.rb +78 -0
- data/app/helpers/solid_objects/actor_helper.rb +13 -4
- data/config/routes.rb +1 -0
- data/db/migrate/20260806000000_add_state_revision_to_solid_objects_instances.rb +12 -0
- data/docs/architecture.md +116 -23
- data/docs/authorization.md +53 -3
- data/docs/benchmarks.md +6 -2
- data/docs/correctness.md +45 -1
- data/docs/database-schema.md +18 -5
- data/docs/development.md +40 -0
- data/docs/fit.md +10 -2
- data/docs/migrating-existing-state.md +8 -1
- data/docs/operations.md +74 -36
- data/docs/realtime.md +106 -12
- data/docs/roadmap.md +14 -7
- data/docs/security.md +25 -3
- data/docs/state-migrations.md +2 -0
- data/examples/application/README.md +5 -4
- data/examples/application/app/views/actors/chat_room_actor/_messages.html.erb +1 -1
- data/examples/application/app/views/chat_rooms/show.html.erb +2 -2
- data/examples/application/config/initializers/solid_objects.rb +9 -3
- data/lib/generators/solid_objects/templates/solid_objects.rb +33 -2
- data/lib/solid_objects/activation.rb +33 -4
- data/lib/solid_objects/actor.rb +58 -6
- data/lib/solid_objects/actor_channel.rb +73 -5
- data/lib/solid_objects/actor_definition.rb +2 -0
- data/lib/solid_objects/actor_snapshot.rb +35 -10
- data/lib/solid_objects/actor_view.rb +103 -10
- data/lib/solid_objects/application_write_guard.rb +24 -0
- data/lib/solid_objects/caller_process.rb +28 -0
- data/lib/solid_objects/cli.rb +44 -5
- data/lib/solid_objects/client.rb +98 -5
- data/lib/solid_objects/commit_action_registry.rb +42 -0
- data/lib/solid_objects/component_path_resolver.rb +27 -0
- data/lib/solid_objects/component_registration.rb +117 -0
- data/lib/solid_objects/component_renderer.rb +82 -0
- data/lib/solid_objects/component_subscriptions.rb +109 -0
- data/lib/solid_objects/component_token.rb +139 -0
- data/lib/solid_objects/component_view.rb +67 -0
- data/lib/solid_objects/configuration.rb +39 -1
- data/lib/solid_objects/database_adapter.rb +28 -1
- data/lib/solid_objects/database_adapters/mysql.rb +46 -0
- data/lib/solid_objects/database_adapters/postgresql.rb +29 -0
- data/lib/solid_objects/database_adapters/sqlite.rb +40 -0
- data/lib/solid_objects/errors.rb +156 -0
- data/lib/solid_objects/executor.rb +65 -4
- data/lib/solid_objects/instance_pruner.rb +97 -0
- data/lib/solid_objects/message_pruner.rb +97 -0
- data/lib/solid_objects/message_reference.rb +9 -0
- data/lib/solid_objects/process_pruner.rb +49 -0
- data/lib/solid_objects/reference.rb +5 -0
- data/lib/solid_objects/state_snapshot.rb +41 -0
- data/lib/solid_objects/stream_token.rb +32 -13
- data/lib/solid_objects/sync_deadline.rb +57 -0
- data/lib/solid_objects/sync_diagnostics.rb +133 -0
- data/lib/solid_objects/synchronous_invocation.rb +26 -7
- data/lib/solid_objects/test_helper.rb +78 -0
- data/lib/solid_objects/turbo_stream_renderer.rb +45 -1
- data/lib/solid_objects/version.rb +1 -1
- data/lib/solid_objects/worker.rb +1 -1
- data/lib/solid_objects.rb +40 -0
- data/sig/generated/controllers/solid_objects/components_controller.rbs +22 -0
- data/sig/generated/lib/solid_objects/activation.rbs +3 -0
- data/sig/generated/lib/solid_objects/actor.rbs +29 -0
- data/sig/generated/lib/solid_objects/actor_channel.rbs +20 -0
- data/sig/generated/lib/solid_objects/actor_snapshot.rbs +17 -0
- data/sig/generated/lib/solid_objects/actor_view.rbs +31 -2
- data/sig/generated/lib/solid_objects/application_write_guard.rbs +8 -0
- data/sig/generated/lib/solid_objects/caller_process.rbs +11 -0
- data/sig/generated/lib/solid_objects/cli.rbs +11 -2
- data/sig/generated/lib/solid_objects/client.rbs +15 -0
- data/sig/generated/lib/solid_objects/commit_action_registry.rbs +43 -0
- data/sig/generated/lib/solid_objects/component_path_resolver.rbs +13 -0
- data/sig/generated/lib/solid_objects/component_registration.rbs +51 -0
- data/sig/generated/lib/solid_objects/component_renderer.rbs +39 -0
- data/sig/generated/lib/solid_objects/component_subscriptions.rbs +40 -0
- data/sig/generated/lib/solid_objects/component_token.rbs +38 -0
- data/sig/generated/lib/solid_objects/component_view.rbs +43 -0
- data/sig/generated/lib/solid_objects/configuration.rbs +35 -7
- data/sig/generated/lib/solid_objects/database_adapter.rbs +9 -0
- data/sig/generated/lib/solid_objects/database_adapters/mysql.rbs +8 -0
- data/sig/generated/lib/solid_objects/database_adapters/postgresql.rbs +8 -0
- data/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs +13 -0
- data/sig/generated/lib/solid_objects/errors.rbs +130 -0
- data/sig/generated/lib/solid_objects/executor.rbs +12 -0
- data/sig/generated/lib/solid_objects/instance_pruner.rbs +36 -0
- data/sig/generated/lib/solid_objects/message_pruner.rbs +42 -0
- data/sig/generated/lib/solid_objects/message_reference.rbs +3 -0
- data/sig/generated/lib/solid_objects/process_pruner.rbs +27 -0
- data/sig/generated/lib/solid_objects/reference.rbs +3 -0
- data/sig/generated/lib/solid_objects/state_snapshot.rbs +30 -0
- data/sig/generated/lib/solid_objects/stream_token.rbs +9 -4
- data/sig/generated/lib/solid_objects/sync_deadline.rbs +31 -0
- data/sig/generated/lib/solid_objects/sync_diagnostics.rbs +34 -0
- data/sig/generated/lib/solid_objects/synchronous_invocation.rbs +3 -0
- data/sig/generated/lib/solid_objects/test_helper.rbs +25 -0
- data/sig/generated/lib/solid_objects/turbo_stream_renderer.rbs +10 -0
- data/sig/generated/lib/solid_objects.rbs +12 -0
- metadata +34 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 557108141818714af50bfee19acda22c9243482e976b50329e5e234aed6d9adb
|
|
4
|
+
data.tar.gz: fe525288022e4bb0630e60bc7ec949e2f61d1ad152fadcaccaeaa41bd1a0376e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e4414b1f2e0d47f95f9307fec6140894bcd8ebcab6dbdc20f54155c332dea76de2728ca7b0bf71bc13c978f2494e059f2099986757c3b933296d460a2bec7a65
|
|
7
|
+
data.tar.gz: 6e807551d58c7d87465bc55b460386f5b9617e36021ddbdfdcbfd25aac31d9c48a7166a818f5a39be3f52eb6a7cb63efd74223a0512fb7a53dce7c55a2c23ca8
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0 - 2026-08-06
|
|
4
|
+
|
|
5
|
+
- Add dependency-driven live ERB components with request-time authorization,
|
|
6
|
+
conventional partial resolution, revision fencing, refresh coalescing, and
|
|
7
|
+
reconnect convergence without broadcasting personalized HTML.
|
|
8
|
+
- Persist a monotonic state revision for secure component refresh ordering.
|
|
9
|
+
- Retry SQLite synchronous lock contention in Ruby so a native busy wait
|
|
10
|
+
cannot starve the thread holding the database lock.
|
|
11
|
+
- Allow maintainers to dispatch CI manually when a push webhook is dropped.
|
|
12
|
+
|
|
13
|
+
## 0.3.0 - 2026-08-06
|
|
14
|
+
|
|
15
|
+
- Reject application-record writes from actor handlers and provide registered
|
|
16
|
+
same-database commit actions for fenced atomic changes.
|
|
17
|
+
- Reject synchronous invocation inside an open Solid Objects transaction and
|
|
18
|
+
add adapter database deadlines, durable diagnostics, and recoverable results
|
|
19
|
+
to sync timeouts.
|
|
20
|
+
- Guard handlers, observables, lifecycle hooks, and state migrations from
|
|
21
|
+
direct application-record writes.
|
|
22
|
+
- Add dry-run-first bounded message, process, and opt-in actor-instance
|
|
23
|
+
pruning, configurable retention, and graceful caller-process shutdown.
|
|
24
|
+
- Add authorized committed state snapshots, mutable JSON copies, commit-action
|
|
25
|
+
instrumentation, and deterministic full-runtime Minitest draining.
|
|
26
|
+
|
|
3
27
|
## 0.2.1 - 2026-08-06
|
|
4
28
|
|
|
5
29
|
- Add `solid_objects:doctor` for configuration, schema, policy, runtime, and
|
data/README.md
CHANGED
|
@@ -24,6 +24,7 @@ end
|
|
|
24
24
|
counter = Counter.ref("global")
|
|
25
25
|
count = counter.increment(amount: 5)
|
|
26
26
|
current_count = counter.value
|
|
27
|
+
current_snapshot = counter.snapshot.value
|
|
27
28
|
|
|
28
29
|
# Durable fire-and-forget delivery. A worker processes it later.
|
|
29
30
|
message = counter.async(:increment, amount: 5)
|
|
@@ -41,13 +42,21 @@ The invocation model is the first adoption decision:
|
|
|
41
42
|
| --- | --- | --- |
|
|
42
43
|
| `counter.increment(amount: 5)` | Committed handler result | No |
|
|
43
44
|
| `counter.sync(:increment, amount: 5)` | Committed handler result | No |
|
|
44
|
-
| `counter.value` |
|
|
45
|
+
| `counter.value` | Ordered, committed query result | No |
|
|
46
|
+
| `counter.snapshot.value` | Current committed state without a mailbox message | No |
|
|
45
47
|
| `counter.async(:increment, amount: 5)` | `MessageReference` immediately | Yes |
|
|
46
48
|
|
|
47
49
|
Direct methods and `sync` durably enqueue the call, then the Rails caller helps
|
|
48
50
|
execute the actor through the same mailbox, lease, and fencing path as a
|
|
49
51
|
worker. `async` only enqueues; a runtime process handles it later.
|
|
50
52
|
|
|
53
|
+
Synchronous calls fail before enqueue when the Solid Objects database
|
|
54
|
+
connection is already inside a transaction. Actor handlers may read application
|
|
55
|
+
records, but direct Active Record writes are rejected so they cannot escape a
|
|
56
|
+
later actor failure. Use a same-database
|
|
57
|
+
[`commit_action`](#application-database-writes) for atomic database changes and
|
|
58
|
+
[`emit`](#effects) for external I/O.
|
|
59
|
+
|
|
51
60
|
Before adopting a latency-sensitive or high-volume surface, read
|
|
52
61
|
[Is Solid Objects a good fit?](docs/fit.md) and the
|
|
53
62
|
[measured performance and row-growth costs](docs/benchmarks.md).
|
|
@@ -56,8 +65,8 @@ This is a port of the programming model, not Cloudflare's edge runtime or
|
|
|
56
65
|
platform. Read the conceptual overview at [solidobjects.dev](https://solidobjects.dev/)
|
|
57
66
|
and the exact Rails guarantees in [Correctness and delivery semantics](docs/correctness.md).
|
|
58
67
|
|
|
59
|
-
|
|
60
|
-
but the project does not yet claim production readiness. See
|
|
68
|
+
Solid Objects is an early release. Its correctness core is implemented and
|
|
69
|
+
tested, but the project does not yet claim production readiness. See
|
|
61
70
|
[Status](#status) and the [roadmap](docs/roadmap.md).
|
|
62
71
|
|
|
63
72
|
## Table of contents
|
|
@@ -65,10 +74,12 @@ but the project does not yet claim production readiness. See
|
|
|
65
74
|
- [Cloudflare Durable Objects for Rails](#cloudflare-durable-objects-for-rails)
|
|
66
75
|
- [Reactive ERB](#reactive-erb)
|
|
67
76
|
- [Installation](#installation)
|
|
77
|
+
- [Upgrading](#upgrading)
|
|
68
78
|
- [Worker requirements](#worker-requirements)
|
|
69
79
|
- [Defining an actor](#defining-an-actor)
|
|
70
80
|
- [Actor identity](#actor-identity)
|
|
71
81
|
- [Invoking an object](#invoking-an-object)
|
|
82
|
+
- [Application database writes](#application-database-writes)
|
|
72
83
|
- [Effects](#effects)
|
|
73
84
|
- [Reminders](#reminders)
|
|
74
85
|
- [Destroying an object](#destroying-an-object)
|
|
@@ -163,42 +174,105 @@ rolled-back state change cannot leak into the page.
|
|
|
163
174
|
Define an observable:
|
|
164
175
|
|
|
165
176
|
```ruby
|
|
166
|
-
class
|
|
167
|
-
attribute :
|
|
177
|
+
class ChatRoom < SolidObjects::Actor
|
|
178
|
+
attribute :recent_messages, default: -> { [] }
|
|
179
|
+
attribute :status, default: "open"
|
|
168
180
|
|
|
169
|
-
observable :
|
|
170
|
-
|
|
181
|
+
observable :message_count do
|
|
182
|
+
recent_messages.length
|
|
171
183
|
end
|
|
184
|
+
|
|
185
|
+
observable :recent_messages
|
|
186
|
+
observable :status
|
|
172
187
|
end
|
|
173
188
|
```
|
|
174
189
|
|
|
175
|
-
|
|
190
|
+
Scalar observables remain stable `<span>` targets:
|
|
176
191
|
|
|
177
192
|
```erb
|
|
178
|
-
<%= solid_object
|
|
179
|
-
|
|
193
|
+
<%= solid_object @room, authorization_context: current_user do |room| %>
|
|
194
|
+
Messages: <%= room.message_count %>
|
|
180
195
|
<% end %>
|
|
181
196
|
```
|
|
182
197
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
198
|
+
Reactive components rerender a host ERB partial when one of their explicit
|
|
199
|
+
dependencies changes:
|
|
200
|
+
|
|
201
|
+
```erb
|
|
202
|
+
<%= solid_object @room, authorization_context: current_user do |room| %>
|
|
203
|
+
<%= room.component :messages, observes: :recent_messages %>
|
|
204
|
+
<%= room.component :presence, observes: %i[recent_messages status] %>
|
|
205
|
+
<% end %>
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
`room.component(:messages)` resolves only
|
|
209
|
+
`actors/chat_room/_messages`. Its partial receives `actor` and
|
|
210
|
+
`authorization_context` locals:
|
|
211
|
+
|
|
212
|
+
```erb
|
|
213
|
+
<ul>
|
|
214
|
+
<% actor.recent_messages.each do |message| %>
|
|
215
|
+
<li><%= message.fetch("body") %></li>
|
|
216
|
+
<% end %>
|
|
217
|
+
</ul>
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Declared observables are deeply frozen ordinary Ruby values inside a
|
|
221
|
+
component. Arrays support loops, hashes support ordinary lookup, conditionals
|
|
222
|
+
work normally, and ERB still escapes user strings. A reactive component cannot
|
|
223
|
+
read `actor.state`, access an undeclared observable, or choose a dynamic
|
|
224
|
+
partial path.
|
|
225
|
+
|
|
226
|
+
That template provides initial server rendering, stable opaque DOM targets,
|
|
227
|
+
and live updates after committed actor turns. One `solid_object` block makes
|
|
228
|
+
one Action Cable subscription for all scalar values and components inside it,
|
|
229
|
+
and Action Cable multiplexes subscriptions over the browser's WebSocket.
|
|
187
230
|
|
|
188
231
|
No client-side state store, custom Stimulus controller, channel class, manual
|
|
189
232
|
broadcast, or one-WebSocket-per-value setup is required. Signed stream tokens
|
|
190
|
-
protect integrity,
|
|
191
|
-
|
|
192
|
-
refresh
|
|
233
|
+
protect integrity, not access. Initial rendering authorizes with the
|
|
234
|
+
`authorization_context` passed to `solid_object`; Cable authorizes with its
|
|
235
|
+
connection; every component refresh authorizes again with a request-specific
|
|
236
|
+
context:
|
|
237
|
+
|
|
238
|
+
```ruby
|
|
239
|
+
SolidObjects.configure do |configuration|
|
|
240
|
+
configuration.component_authorization_context = ->(controller:) { Current.user }
|
|
241
|
+
end
|
|
242
|
+
```
|
|
193
243
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
Turbo
|
|
197
|
-
|
|
244
|
+
The durable outbox stores one row per changed observable, never personalized
|
|
245
|
+
HTML. Cable sends invalidation metadata over the shared actor stream, then a
|
|
246
|
+
Turbo Frame requests the component with normal cookies. Only scalar targets
|
|
247
|
+
that the server rendered into this `solid_object` scope are signed into its
|
|
248
|
+
stream token and receive value payloads; component-only dependencies do not
|
|
249
|
+
send their values to the browser. The endpoint renders the latest committed
|
|
250
|
+
snapshot, returns `private, no-store`, and reauthorizes the component name plus
|
|
251
|
+
every declared dependency. Two viewers can therefore receive different HTML
|
|
252
|
+
for the same actor without sharing either projection.
|
|
253
|
+
|
|
254
|
+
Reconnect compares the component's signed initial revision with the latest
|
|
255
|
+
actor incarnation and state revision, then refreshes stale components. Cable
|
|
256
|
+
coalesces several dependency changes from one actor turn into one component
|
|
257
|
+
refresh and ignores older out-of-order invalidations. A newer invalidation
|
|
258
|
+
replaces an in-flight frame, so its detached older response cannot overwrite
|
|
259
|
+
newer state.
|
|
260
|
+
|
|
261
|
+
Reactive components add no HTML to durable rows, but each affected component
|
|
262
|
+
causes an authorized HTTP render. One actor turn still inserts one broadcast
|
|
263
|
+
row per changed observable; several dependencies from that turn coalesce at
|
|
264
|
+
the subscriber. Keep components bounded, declare only necessary dependencies,
|
|
265
|
+
and use scalar observables for inexpensive single-value replacement.
|
|
198
266
|
|
|
199
267
|
Reactive views require `turbo-rails` and a working Action Cable adapter in the
|
|
200
|
-
host application.
|
|
201
|
-
|
|
268
|
+
host application. The Solid Objects engine must be mounted so its signed
|
|
269
|
+
component endpoint is reachable. Reactive views are optional; the actor
|
|
270
|
+
runtime itself does not depend on Turbo.
|
|
271
|
+
|
|
272
|
+
```ruby
|
|
273
|
+
# config/routes.rb
|
|
274
|
+
mount SolidObjects::Engine => "/solid_objects"
|
|
275
|
+
```
|
|
202
276
|
|
|
203
277
|
## Installation
|
|
204
278
|
|
|
@@ -264,6 +338,50 @@ can generate the gem RBI with:
|
|
|
264
338
|
bundle exec tapioca gem solid_objects
|
|
265
339
|
```
|
|
266
340
|
|
|
341
|
+
## Upgrading
|
|
342
|
+
|
|
343
|
+
Review [CHANGELOG.md](CHANGELOG.md) for compatibility and deployment-order
|
|
344
|
+
notes, then update the gem:
|
|
345
|
+
|
|
346
|
+
```bash
|
|
347
|
+
bundle update solid_objects
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
If the `Gemfile` pins an exact version, update that constraint first and run
|
|
351
|
+
`bundle install`. Commit both `Gemfile.lock` and the copied Solid Objects
|
|
352
|
+
migrations.
|
|
353
|
+
|
|
354
|
+
Copy only migrations that the newer gem has added, migrate, and verify the
|
|
355
|
+
installation:
|
|
356
|
+
|
|
357
|
+
```bash
|
|
358
|
+
bin/rails solid_objects:install:migrations
|
|
359
|
+
bin/rails db:migrate
|
|
360
|
+
bin/rails solid_objects:doctor
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
The migration task skips engine migrations already present in the application
|
|
364
|
+
and gives new migrations host-specific timestamps. Inspect the resulting
|
|
365
|
+
`db/migrate/*.solid_objects.rb` files before applying them. Do not rerun
|
|
366
|
+
`generate solid_objects:install` during an upgrade because that also attempts
|
|
367
|
+
to regenerate the application initializer.
|
|
368
|
+
|
|
369
|
+
When Solid Objects uses a separate database configuration named `actors`, copy
|
|
370
|
+
and run migrations through that database's configured migration path:
|
|
371
|
+
|
|
372
|
+
```bash
|
|
373
|
+
DATABASE=actors bin/rails solid_objects:install:migrations
|
|
374
|
+
bin/rails db:migrate:actors
|
|
375
|
+
bin/rails solid_objects:doctor
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
For production, back up the actor database and run new migrations before
|
|
379
|
+
starting application or Solid Objects worker processes that require the new
|
|
380
|
+
schema. Restart the web and Solid Objects worker fleet after the bundle and
|
|
381
|
+
schema are current. For releases that change actor state versions, also follow
|
|
382
|
+
the [state migration and rolling-deployment guide](docs/state-migrations.md);
|
|
383
|
+
Rails schema migrations and actor state migrations are separate concerns.
|
|
384
|
+
|
|
267
385
|
## Worker requirements
|
|
268
386
|
|
|
269
387
|
Synchronous actors can be adopted without adding a long-running process. Start
|
|
@@ -273,13 +391,14 @@ the runtime when the feature introduces asynchronous delivery or outboxes:
|
|
|
273
391
|
| --- | --- |
|
|
274
392
|
| Direct actor method or explicit `sync` | None; the caller executes it |
|
|
275
393
|
| Attribute or declared query read | None; the caller executes it |
|
|
394
|
+
| Committed `snapshot` read | None; reads the instance row directly |
|
|
276
395
|
| `destroy` | None |
|
|
277
396
|
| `async` including delayed delivery | Actor worker |
|
|
278
397
|
| One-shot or recurring `schedule` | Reminder scheduler and actor worker |
|
|
279
398
|
| `emit` without an actor callback | Effect worker |
|
|
280
399
|
| `emit` with success or failure callback | Effect worker and actor worker |
|
|
281
400
|
| Actor-to-actor `async` or `send_to` | Effect worker and actor worker |
|
|
282
|
-
|
|
|
401
|
+
| Scalar or component Turbo updates | Broadcast worker, Action Cable, and the actor execution path |
|
|
283
402
|
| Initial `solid_object` server render | No Solid Objects worker; normal Rails rendering |
|
|
284
403
|
|
|
285
404
|
One command starts every Solid Objects role:
|
|
@@ -346,6 +465,20 @@ mailbox. State changes must go through public actor methods or explicit
|
|
|
346
465
|
State, arguments, results, effects, and reminder arguments accept
|
|
347
466
|
JSON-compatible values. Solid Objects never deserializes Ruby `Marshal` data.
|
|
348
467
|
|
|
468
|
+
Attribute readers are ordered mailbox queries and retain message history. For
|
|
469
|
+
a read that does not need mailbox ordering, use an authorized committed
|
|
470
|
+
snapshot:
|
|
471
|
+
|
|
472
|
+
```ruby
|
|
473
|
+
snapshot = cart.snapshot
|
|
474
|
+
items = snapshot.items
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
Snapshots and synchronous results are deeply frozen. Use
|
|
478
|
+
`SolidObjects.mutable_copy(items)` before changing a returned collection.
|
|
479
|
+
Snapshot reads can race with an in-flight turn; they return the most recently
|
|
480
|
+
committed state and do not create or activate a missing actor.
|
|
481
|
+
|
|
349
482
|
Lifecycle hooks are also available:
|
|
350
483
|
|
|
351
484
|
```ruby
|
|
@@ -451,6 +584,37 @@ and MCP request/response boundaries when the handler itself fits the
|
|
|
451
584
|
application's latency budget. If another process owns the activation, the
|
|
452
585
|
caller waits for the durable result using wake-up hints with bounded database
|
|
453
586
|
polling as the fallback. A timeout never cancels the durable invocation.
|
|
587
|
+
`SolidObjects::SyncTimeout` includes actor identity, message ID, sequence,
|
|
588
|
+
durable status, mailbox blocker, and activation-owner diagnostics without
|
|
589
|
+
including message arguments. The configured timeout also bounds adapter
|
|
590
|
+
database lock waits from the enqueue attempt through result observation.
|
|
591
|
+
PostgreSQL uses transaction lock and statement timeouts, SQLite uses its busy
|
|
592
|
+
timeout, and MySQL uses its execution timeout plus InnoDB's one-second minimum
|
|
593
|
+
lock-wait granularity.
|
|
594
|
+
|
|
595
|
+
The durable call can finish after its original caller gives up. Reauthorize and
|
|
596
|
+
recover its eventual result through the durable message identity:
|
|
597
|
+
|
|
598
|
+
```ruby
|
|
599
|
+
begin
|
|
600
|
+
order.submit(timeout: 250.milliseconds)
|
|
601
|
+
rescue SolidObjects::SyncTimeout => error
|
|
602
|
+
result = error.message_reference.wait(
|
|
603
|
+
timeout: 5.seconds,
|
|
604
|
+
authorization_context: Current.user
|
|
605
|
+
)
|
|
606
|
+
end
|
|
607
|
+
```
|
|
608
|
+
|
|
609
|
+
If the enqueue transaction itself cannot finish within the budget, Solid
|
|
610
|
+
Objects raises `SyncEnqueueTimeout`; no durable message exists to recover.
|
|
611
|
+
Timeouts do not preempt Ruby handler code that has already started.
|
|
612
|
+
|
|
613
|
+
Do not wrap a synchronous actor call in `ApplicationRecord.transaction`.
|
|
614
|
+
Solid Objects raises `SolidObjects::SyncInsideTransaction` before enqueue when
|
|
615
|
+
its connection already has an open transaction. Move the actor call before the
|
|
616
|
+
transaction, use `async`, or let the actor own the coordinated change through a
|
|
617
|
+
commit action.
|
|
454
618
|
|
|
455
619
|
Actor code cannot use direct calls or `sync` on another actor; synchronous
|
|
456
620
|
actor-to-actor waits can deadlock in cycles. Use `async` or `send_to` and a
|
|
@@ -492,6 +656,50 @@ end
|
|
|
492
656
|
|
|
493
657
|
External systems must also deduplicate effects using the stable effect ID.
|
|
494
658
|
|
|
659
|
+
## Application database writes
|
|
660
|
+
|
|
661
|
+
Actor handlers execute outside the fenced commit. They may query application
|
|
662
|
+
records, but Solid Objects rejects direct Active Record writes from all
|
|
663
|
+
user-supplied actor code: handlers, observables, activation/deactivation hooks,
|
|
664
|
+
and state migrations. Otherwise an application row could commit before the
|
|
665
|
+
actor later raises or loses its activation fence.
|
|
666
|
+
|
|
667
|
+
For a short database-only change that must commit atomically with actor state,
|
|
668
|
+
stage a named action:
|
|
669
|
+
|
|
670
|
+
```ruby
|
|
671
|
+
class Assessment < SolidObjects::Actor
|
|
672
|
+
attribute :status, default: "open"
|
|
673
|
+
|
|
674
|
+
def finish(attempt_id:, score:)
|
|
675
|
+
self.status = "complete"
|
|
676
|
+
commit_action :complete_attempt, attempt_id:, score:
|
|
677
|
+
end
|
|
678
|
+
end
|
|
679
|
+
```
|
|
680
|
+
|
|
681
|
+
Register its implementation during application boot:
|
|
682
|
+
|
|
683
|
+
```ruby
|
|
684
|
+
SolidObjects.register_commit_action(:complete_attempt) do |arguments, context|
|
|
685
|
+
AssessmentAttempt.find(arguments.fetch("attempt_id")).update!(
|
|
686
|
+
score: arguments.fetch("score"),
|
|
687
|
+
actor_message_id: context.message_id
|
|
688
|
+
)
|
|
689
|
+
end
|
|
690
|
+
```
|
|
691
|
+
|
|
692
|
+
The registered block runs inside the short fenced transaction. Its database
|
|
693
|
+
writes, actor state, message completion, and outboxes all commit or roll back
|
|
694
|
+
together. Commit actions require Solid Objects and `ActiveRecord::Base` to
|
|
695
|
+
share one connection pool. They may be invoked again after a database rollback,
|
|
696
|
+
so keep them deterministic, bounded, and database-only. Never perform network
|
|
697
|
+
I/O, wait for another actor, or enqueue nontransactional work from a commit
|
|
698
|
+
action.
|
|
699
|
+
|
|
700
|
+
When Solid Objects uses a separate actor database, use `emit` and an idempotent
|
|
701
|
+
effect consumer instead; the two databases cannot share one transaction.
|
|
702
|
+
|
|
495
703
|
## Effects
|
|
496
704
|
|
|
497
705
|
Cloudflare Durable Objects can call external services directly. Solid Objects
|
|
@@ -555,6 +763,10 @@ It may read `SolidObjects::Instance.states_for`, `.without_pending_work`, and
|
|
|
555
763
|
`.orphaned`, but every repair must go through `async`. Never bulk-update actor
|
|
556
764
|
state around the lease and fencing checks.
|
|
557
765
|
|
|
766
|
+
Suspended actors should be reported rather than silently resumed. Spread large
|
|
767
|
+
repair batches with `available_at:` so reconciliation cannot stampede one
|
|
768
|
+
mailbox or the worker fleet.
|
|
769
|
+
|
|
558
770
|
## Destroying an object
|
|
559
771
|
|
|
560
772
|
Destroy an actor incarnation through its reference:
|
|
@@ -631,6 +843,11 @@ Important defaults:
|
|
|
631
843
|
| `max_attempts` | 5 |
|
|
632
844
|
| `process_heartbeat_interval` | 15 seconds |
|
|
633
845
|
| `process_alive_threshold` | 60 seconds |
|
|
846
|
+
| `message_retention` | 30 days |
|
|
847
|
+
| `message_retention_by_actor_type` | `{}` |
|
|
848
|
+
| `instance_retention_by_actor_type` | `{}`; instances never expire unless listed |
|
|
849
|
+
| `process_retention` | 7 days |
|
|
850
|
+
| `prune_batch_size` | 1,000 |
|
|
634
851
|
| `worker_count` | 1 |
|
|
635
852
|
| `effect_worker_count` | 1 |
|
|
636
853
|
| `broadcast_worker_count` | 1 |
|
|
@@ -664,10 +881,16 @@ Administration commands require the administration policy:
|
|
|
664
881
|
```bash
|
|
665
882
|
bundle exec solid_objects status
|
|
666
883
|
bundle exec solid_objects cleanup
|
|
884
|
+
bundle exec solid_objects prune_messages
|
|
885
|
+
bundle exec solid_objects prune_instances
|
|
886
|
+
bundle exec solid_objects prune_processes
|
|
667
887
|
bundle exec solid_objects dead_letters
|
|
668
888
|
bundle exec solid_objects retry_dead_letter 123
|
|
669
889
|
```
|
|
670
890
|
|
|
891
|
+
The prune commands preview counts by default. Add `--execute` only after
|
|
892
|
+
reviewing the configured retention policy.
|
|
893
|
+
|
|
671
894
|
The supervisor stops new claims, drains active loops, releases cached leases,
|
|
672
895
|
and marks process rows stopped on graceful shutdown. A hard-killed worker's
|
|
673
896
|
claimed turn is recovered after its process heartbeat or activation lease
|
|
@@ -814,11 +1037,13 @@ See the [development guide](docs/development.md) and
|
|
|
814
1037
|
|
|
815
1038
|
## Status
|
|
816
1039
|
|
|
817
|
-
Implemented and tested in 0.
|
|
1040
|
+
Implemented and tested in 0.4:
|
|
818
1041
|
|
|
819
1042
|
- Rails engine, install generator, migrations, and `solid_objects` executable;
|
|
820
1043
|
- actor registry, references, JSON state, and state migrations;
|
|
821
1044
|
- direct synchronous actor RPC, explicit `sync`, and durable `async`;
|
|
1045
|
+
- guarded transaction boundaries, same-database commit actions, adapter lock
|
|
1046
|
+
deadlines, structured synchronous timeout diagnostics, and result recovery;
|
|
822
1047
|
- durable message history plus ready and claimed membership tables;
|
|
823
1048
|
- concurrent sequence allocation and actor creation;
|
|
824
1049
|
- activation leases, per-activation tokens, fencing generations, and
|
|
@@ -830,8 +1055,13 @@ Implemented and tested in 0.2:
|
|
|
830
1055
|
- one-shot and recurring per-actor reminders;
|
|
831
1056
|
- authorized actor destruction with fenced stale-write rejection and cascading
|
|
832
1057
|
durable-work cleanup;
|
|
833
|
-
- durable observable
|
|
834
|
-
-
|
|
1058
|
+
- durable observable invalidations, scalar Turbo replacement, and authorized
|
|
1059
|
+
request-time ERB component refresh;
|
|
1060
|
+
- process registration, heartbeats, caller shutdown, cleanup, and bounded
|
|
1061
|
+
message/process retention plus opt-in actor-instance expiration;
|
|
1062
|
+
- an opt-in Minitest helper for actor-state isolation and deterministic async
|
|
1063
|
+
actor/reminder/effect/broadcast draining;
|
|
1064
|
+
- authorized mailbox-free state snapshots and mutable JSON copies; and
|
|
835
1065
|
- SQLite, PostgreSQL, and MySQL integration tests.
|
|
836
1066
|
|
|
837
1067
|
Partially implemented:
|
|
@@ -840,12 +1070,12 @@ Partially implemented:
|
|
|
840
1070
|
run periodic maintenance automatically;
|
|
841
1071
|
- cross-process wake-up uses polling; PostgreSQL notifications and optional
|
|
842
1072
|
Redis acceleration are not implemented;
|
|
843
|
-
- live observable replacement
|
|
844
|
-
|
|
1073
|
+
- live observable and component replacement work, while Turbo append actions
|
|
1074
|
+
remain future work;
|
|
845
1075
|
- local admission limits exist, but distributed rate limits and global
|
|
846
1076
|
admission control do not; and
|
|
847
|
-
- administration views exist, but
|
|
848
|
-
do not.
|
|
1077
|
+
- administration views and pruning commands exist, but scheduled maintenance
|
|
1078
|
+
and richer audit tools do not.
|
|
849
1079
|
|
|
850
1080
|
Production readiness requires hardening and operational soak evidence. The
|
|
851
1081
|
[roadmap](docs/roadmap.md) tracks that work.
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
require "action_controller/base"
|
|
4
|
+
|
|
5
|
+
module SolidObjects
|
|
6
|
+
class ComponentsController < ActionController::Base
|
|
7
|
+
protect_from_forgery with: :exception
|
|
8
|
+
|
|
9
|
+
# @rbs () -> void
|
|
10
|
+
def show
|
|
11
|
+
registration = ComponentRegistration.from_token(
|
|
12
|
+
params.require(:token)
|
|
13
|
+
)
|
|
14
|
+
requested_revision = requested_revision_key
|
|
15
|
+
snapshot = ActorSnapshot.new(registration.reference)
|
|
16
|
+
return head :conflict if newer_than_snapshot?(requested_revision, snapshot)
|
|
17
|
+
|
|
18
|
+
authorization_context = SolidObjects
|
|
19
|
+
.configuration
|
|
20
|
+
.component_authorization_context
|
|
21
|
+
.call(controller: self)
|
|
22
|
+
rendered = ComponentRenderer.new(
|
|
23
|
+
snapshot:,
|
|
24
|
+
component_name: registration.component_name,
|
|
25
|
+
dependencies: registration.dependencies,
|
|
26
|
+
view_context: component_view_context,
|
|
27
|
+
authorization_context:
|
|
28
|
+
).call
|
|
29
|
+
response.headers["Cache-Control"] = "private, no-store"
|
|
30
|
+
render html: component_frame(registration, snapshot, rendered)
|
|
31
|
+
rescue Unauthorized
|
|
32
|
+
head :forbidden
|
|
33
|
+
rescue UnknownComponent
|
|
34
|
+
head :not_found
|
|
35
|
+
rescue ActionController::ParameterMissing,
|
|
36
|
+
ArgumentError,
|
|
37
|
+
InvalidComponentToken
|
|
38
|
+
head :bad_request
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
# @rbs () -> Array[Integer]
|
|
44
|
+
def requested_revision_key
|
|
45
|
+
instance_id = Integer(params.fetch(:instance_id), 10)
|
|
46
|
+
revision = Integer(params.fetch(:revision), 10)
|
|
47
|
+
raise ArgumentError if instance_id.negative? || revision.negative?
|
|
48
|
+
|
|
49
|
+
[ instance_id, revision ]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @rbs (Array[Integer], ActorSnapshot) -> bool
|
|
53
|
+
def newer_than_snapshot?(requested_revision, snapshot)
|
|
54
|
+
(requested_revision <=> [ snapshot.instance_id, snapshot.revision ]) == 1
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @rbs () -> untyped
|
|
58
|
+
def component_view_context
|
|
59
|
+
if defined?(Rails) && Rails.application
|
|
60
|
+
prepend_view_path(*Rails.application.paths["app/views"].existent)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
view_context.tap do |context|
|
|
64
|
+
context.extend(Rails.application.helpers) if defined?(Rails) && Rails.application
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# @rbs (ComponentRegistration, ActorSnapshot, untyped) -> String
|
|
69
|
+
def component_frame(registration, snapshot, rendered)
|
|
70
|
+
target = DomIdentity.component(
|
|
71
|
+
registration.reference,
|
|
72
|
+
registration.component_name
|
|
73
|
+
)
|
|
74
|
+
revision = "#{snapshot.instance_id}:#{snapshot.revision}"
|
|
75
|
+
%(<turbo-frame id="#{target}" data-solid-objects-revision="#{revision}">#{rendered}</turbo-frame>).html_safe
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -9,11 +9,20 @@ module SolidObjects
|
|
|
9
9
|
view_context: self,
|
|
10
10
|
authorization_context:
|
|
11
11
|
)
|
|
12
|
-
subscription = tag.turbo_cable_stream_source(
|
|
13
|
-
channel: "SolidObjects::ActorChannel",
|
|
14
|
-
token: StreamToken.generate(reference)
|
|
15
|
-
)
|
|
16
12
|
content = capture(actor, &block)
|
|
13
|
+
subscription_attributes = {
|
|
14
|
+
channel: "SolidObjects::ActorChannel",
|
|
15
|
+
token: StreamToken.generate(
|
|
16
|
+
reference,
|
|
17
|
+
observables: actor.scalar_observable_names
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
if actor.component_tokens.any?
|
|
21
|
+
subscription_attributes[:data] = {
|
|
22
|
+
components: JSON.generate(actor.component_tokens)
|
|
23
|
+
}
|
|
24
|
+
end
|
|
25
|
+
subscription = tag.turbo_cable_stream_source(**subscription_attributes)
|
|
17
26
|
|
|
18
27
|
content_tag(
|
|
19
28
|
:div,
|
data/config/routes.rb
CHANGED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
class AddStateRevisionToSolidObjectsInstances < ActiveRecord::Migration[8.0]
|
|
4
|
+
# @rbs () -> void
|
|
5
|
+
def change
|
|
6
|
+
add_column SolidObjects.table_name(:instances),
|
|
7
|
+
:state_revision,
|
|
8
|
+
:bigint,
|
|
9
|
+
null: false,
|
|
10
|
+
default: 0
|
|
11
|
+
end
|
|
12
|
+
end
|