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
data/docs/architecture.md
CHANGED
|
@@ -120,7 +120,11 @@ instance first prevents a claimed reminder from recreating a destroyed actor.
|
|
|
120
120
|
|
|
121
121
|
### Broadcast worker
|
|
122
122
|
|
|
123
|
-
The broadcast worker claims committed observable-change rows, renders
|
|
123
|
+
The broadcast worker claims committed observable-change rows, renders
|
|
124
|
+
idempotent scalar Turbo replacements with component invalidation metadata,
|
|
125
|
+
broadcasts to a signed actor stream, and records delivery. It never renders
|
|
126
|
+
personalized component HTML. Current actor state remains the reconnect and
|
|
127
|
+
request-time component source of truth.
|
|
124
128
|
|
|
125
129
|
### Process registry
|
|
126
130
|
|
|
@@ -150,6 +154,12 @@ snapshots are deeply frozen. Use `async` for durable fire-and-forget delivery
|
|
|
150
154
|
and `sync` for dynamic operation names. The explicit `message` DSL remains
|
|
151
155
|
available for dynamic definitions.
|
|
152
156
|
|
|
157
|
+
`reference.snapshot` is the explicit unordered read path. It invokes query
|
|
158
|
+
authorization, reads the most recently committed instance state without
|
|
159
|
+
creating a message or activation, applies required state migrations in memory,
|
|
160
|
+
and returns deeply frozen declared attributes. It can race with an in-flight
|
|
161
|
+
turn. `SolidObjects.mutable_copy` creates an independent mutable JSON value.
|
|
162
|
+
|
|
153
163
|
`message` and `query` both execute as durable mailbox turns. A query may not
|
|
154
164
|
mutate state. The executor detects query mutation and fails the message. An
|
|
155
165
|
observable is a named projection of state used by server rendering and realtime
|
|
@@ -161,6 +171,8 @@ Lifecycle hooks are deterministic local hooks:
|
|
|
161
171
|
- `on_deactivate` runs only on graceful local deactivation. Its state changes are not persisted and it must not be used for durable work. Explicit destruction does not run lifecycle hooks.
|
|
162
172
|
|
|
163
173
|
Durable application cleanup belongs in messages, reminders, or effects.
|
|
174
|
+
Handlers, observable blocks, lifecycle hooks, and state migrations all execute
|
|
175
|
+
with application Active Record writes prevented.
|
|
164
176
|
|
|
165
177
|
## Enqueue and sequence allocation
|
|
166
178
|
|
|
@@ -248,6 +260,8 @@ Actor code then executes with no open database transaction and no pinned connect
|
|
|
248
260
|
|
|
249
261
|
- Read and mutate its in-memory state for a message
|
|
250
262
|
- Read state for a query
|
|
263
|
+
- Read application records
|
|
264
|
+
- Stage same-database commit actions
|
|
251
265
|
- Stage effects
|
|
252
266
|
- Stage reminders
|
|
253
267
|
- Stage asynchronous actor messages
|
|
@@ -258,8 +272,16 @@ It cannot:
|
|
|
258
272
|
- Perform a synchronous actor-to-actor wait
|
|
259
273
|
- Assume execution happens once
|
|
260
274
|
- Commit actor state directly
|
|
275
|
+
- Write application records directly
|
|
276
|
+
- Perform external I/O that must be atomic with actor state
|
|
261
277
|
|
|
262
|
-
|
|
278
|
+
Rails write prevention turns a direct Active Record write into
|
|
279
|
+
`ApplicationWriteForbidden` before it reaches the database. Handler and
|
|
280
|
+
observable failures become nonretryable message failures. Activation and
|
|
281
|
+
migration failures happen before the message is claimed. A deactivation-hook
|
|
282
|
+
failure is instrumented and logged, but cannot replace an already committed
|
|
283
|
+
result or prevent best-effort lease release. After actor code, the executor
|
|
284
|
+
validates state and staged data as JSON and computes changed observables.
|
|
263
285
|
|
|
264
286
|
## Fenced commit
|
|
265
287
|
|
|
@@ -268,14 +290,16 @@ Successful completion uses one database transaction:
|
|
|
268
290
|
1. Lock the instance row.
|
|
269
291
|
2. Verify owner, activation token, generation, and an unexpired lease using database time.
|
|
270
292
|
3. Lock the durable message and verify its claimed membership belongs to that owner, activation token, and generation.
|
|
271
|
-
4.
|
|
272
|
-
5.
|
|
273
|
-
6.
|
|
274
|
-
7.
|
|
275
|
-
8. Insert staged
|
|
276
|
-
9. Insert
|
|
277
|
-
10.
|
|
278
|
-
11.
|
|
293
|
+
4. Execute registered same-database commit actions.
|
|
294
|
+
5. Update native JSON state and state version.
|
|
295
|
+
6. Advance the actor state revision to the completed message sequence.
|
|
296
|
+
7. Store the completion timestamp and result on the durable message and delete claimed membership.
|
|
297
|
+
8. Insert staged effects.
|
|
298
|
+
9. Insert or update staged reminders.
|
|
299
|
+
10. Insert staged actor-message outbox rows.
|
|
300
|
+
11. Insert changed-observable broadcast rows.
|
|
301
|
+
12. Update actor last-used time.
|
|
302
|
+
13. Commit.
|
|
279
303
|
|
|
280
304
|
Any lease or message predicate failure raises `LostActivation` and rolls back every item. The stale worker discards its in-memory activation.
|
|
281
305
|
|
|
@@ -346,11 +370,25 @@ overhead at or below 100 milliseconds; polling fallback can pay up to
|
|
|
346
370
|
Caller timeout:
|
|
347
371
|
|
|
348
372
|
- Raises `SolidObjects::SyncTimeout`.
|
|
373
|
+
- Reports durable message, blocker, and activation-owner diagnostics.
|
|
374
|
+
- Exposes a `message_reference` that can reauthorize and wait again.
|
|
349
375
|
- Does not cancel or delete the message.
|
|
350
376
|
- Does not prevent later execution.
|
|
351
377
|
- Leaves the result available until retention cleanup.
|
|
352
378
|
|
|
353
|
-
The
|
|
379
|
+
The configured timeout begins before durable enqueue. PostgreSQL transaction
|
|
380
|
+
lock and statement timeouts, MySQL execution and InnoDB lock-wait timeouts, and
|
|
381
|
+
SQLite busy timeout bound database contention. MySQL rounds lock waits up to
|
|
382
|
+
its one-second minimum. If enqueue cannot commit, `SyncEnqueueTimeout` is
|
|
383
|
+
raised and no durable result exists. Once Ruby handler code starts, it is not
|
|
384
|
+
safely preempted and may outlive the caller budget.
|
|
385
|
+
|
|
386
|
+
The durable row remains after a normal wait timeout and can be recovered with
|
|
387
|
+
`error.message_reference.wait`. Bounded retention commands are implemented;
|
|
388
|
+
public request-ID lookup is not. A sync call made with an already-open
|
|
389
|
+
transaction on the Solid Objects connection raises `SyncInsideTransaction`
|
|
390
|
+
before enqueue. This avoids savepoint enlistment, lock retention until the
|
|
391
|
+
outer commit, and callers timing out on work they indirectly block.
|
|
354
392
|
|
|
355
393
|
`async` performs the same durable enqueue without caller assistance or result
|
|
356
394
|
waiting and immediately returns a `MessageReference`. Runtime workers process
|
|
@@ -427,25 +465,61 @@ Large repairs use `async(..., available_at:)` to spread work over an application
|
|
|
427
465
|
|
|
428
466
|
## Realtime integration
|
|
429
467
|
|
|
430
|
-
`solid_object` performs an authorized state read for initial rendering and
|
|
468
|
+
`solid_object` performs an authorized state read for initial rendering and
|
|
469
|
+
emits:
|
|
431
470
|
|
|
432
471
|
- A stable scope DOM ID derived from actor type and a SHA-256 digest of actor ID
|
|
433
472
|
- One Turbo Cable subscription element for the actor
|
|
434
473
|
- Stable child target IDs for values and components
|
|
435
474
|
- A signed actor token used by the channel subscription
|
|
475
|
+
- Signed component registrations containing a conventional component name,
|
|
476
|
+
explicit observable dependencies, the initial actor incarnation/revision,
|
|
477
|
+
and a same-origin engine refresh path
|
|
436
478
|
|
|
437
479
|
```erb
|
|
438
480
|
<%= solid_object current_cart do |cart| %>
|
|
439
481
|
Cart items: <%= cart.items_count %>
|
|
440
|
-
<%= cart.component :summary %>
|
|
482
|
+
<%= cart.component :summary, observes: %i[items checkout_status] %>
|
|
441
483
|
<% end %>
|
|
442
484
|
```
|
|
443
485
|
|
|
444
486
|
The signed token proves integrity, not authorization. `ActorChannel#subscribed` verifies the token, resolves the registered actor type, invokes `authorize_subscription`, and only then streams.
|
|
445
487
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
488
|
+
Scalar observable calls remain direct escaped Turbo replacements. A reactive
|
|
489
|
+
component resolves only `actors/<actor_class>/_<component>`, receives its
|
|
490
|
+
declared observables as frozen Ruby values, and cannot read raw state or a
|
|
491
|
+
dependency it did not declare. A static initial-only component can still use a
|
|
492
|
+
server-selected explicit partial; a reactive component cannot.
|
|
493
|
+
|
|
494
|
+
Broadcast replacements happen after the actor transaction commits because only
|
|
495
|
+
a committed broadcast outbox row can be delivered. Multiple scalar values and
|
|
496
|
+
components share the same Action Cable connection and one actor subscription.
|
|
497
|
+
One outbox row still exists per changed observable.
|
|
498
|
+
|
|
499
|
+
The shared stream contains invalidation metadata and scalar HTML only for the
|
|
500
|
+
scalar targets signed into that scope's stream token. Component-only
|
|
501
|
+
dependencies do not send their values to the browser, and the stream never
|
|
502
|
+
contains personalized component HTML. For each subscription, `ActorChannel`
|
|
503
|
+
matches the changed observable to registered component dependencies. It
|
|
504
|
+
coalesces multiple dependencies at the same message sequence and drops older
|
|
505
|
+
revision pairs. A component invalidation replaces its stable target with a
|
|
506
|
+
Turbo Frame whose source is the signed engine endpoint.
|
|
507
|
+
|
|
508
|
+
The browser then makes an ordinary cookie-bearing HTTP request. The engine
|
|
509
|
+
controller derives a request-specific context through
|
|
510
|
+
`component_authorization_context`, calls `authorize_query` for the component
|
|
511
|
+
name and every declared dependency, renders the host partial from a new
|
|
512
|
+
committed snapshot, and returns `private, no-store` HTML. Subscribers to the
|
|
513
|
+
same actor can therefore receive different HTML without sharing it through
|
|
514
|
+
Cable or the database.
|
|
515
|
+
|
|
516
|
+
Each channel subscription transmits current scalar replacements and compares
|
|
517
|
+
each component's signed initial revision against the latest committed
|
|
518
|
+
`(instance_id, state_revision)` pair, including after reconnect. Missing a
|
|
519
|
+
broadcast therefore creates temporary staleness, not permanent divergence.
|
|
520
|
+
The instance primary key distinguishes destroy-and-recreate incarnations.
|
|
521
|
+
Replacing the full frame on each newer invalidation detaches an older in-flight
|
|
522
|
+
frame, preventing its slower response from replacing the current generation.
|
|
449
523
|
|
|
450
524
|
## Authorization
|
|
451
525
|
|
|
@@ -464,6 +538,11 @@ recognizable.
|
|
|
464
538
|
|
|
465
539
|
No controller, channel, or administrative command treats an actor ID, message ID, request ID, or signed stream name as authorization.
|
|
466
540
|
|
|
541
|
+
Initial component rendering, Cable subscription, and request-time component
|
|
542
|
+
refresh deliberately use different authorization contexts. Signed component
|
|
543
|
+
tokens constrain actor identity, component convention, dependencies, revision,
|
|
544
|
+
and same-origin refresh path but never grant access.
|
|
545
|
+
|
|
467
546
|
Actor IDs are bounded UTF-8 strings and never become constant names, SQL identifiers, file paths, or raw stream names.
|
|
468
547
|
|
|
469
548
|
## Serialization
|
|
@@ -505,6 +584,7 @@ end
|
|
|
505
584
|
```
|
|
506
585
|
|
|
507
586
|
Activation applies each step in order. Missing steps, cycles, non-JSON output, or stored versions newer than code fail activation.
|
|
587
|
+
Migration code is subject to the same application-write guard as handlers.
|
|
508
588
|
|
|
509
589
|
Refusing newer stored state is a runtime invariant, not an operator recommendation. The worker does not invoke lifecycle hooks or message code when `stored_state_version > actor_class.state_version`.
|
|
510
590
|
|
|
@@ -573,6 +653,19 @@ An activation becomes idle when it has no due earliest message and no turn in fl
|
|
|
573
653
|
Graceful release conditionally clears owner and expiration only if the generation still matches. Expired leases need no explicit cleanup before another worker claims them.
|
|
574
654
|
|
|
575
655
|
`on_deactivate` is best effort and nondurable. It may not run on crash and cannot be the source of a correctness requirement.
|
|
656
|
+
A failing hook is logged and instrumented as
|
|
657
|
+
`solid_objects.activation.deactivation_failed`; lease release is still
|
|
658
|
+
attempted and a committed synchronous result is preserved.
|
|
659
|
+
|
|
660
|
+
## Retention
|
|
661
|
+
|
|
662
|
+
Terminal messages and stopped process rows have configurable bounded pruning.
|
|
663
|
+
Actor instances never expire by default. Types listed in
|
|
664
|
+
`instance_retention_by_actor_type` become eligible after their idle cutoff only
|
|
665
|
+
when they are unowned, unpaused, and have no ready/claimed work, scheduled
|
|
666
|
+
reminder, unfinished or dead outbox, or dead letter. The pruner locks and
|
|
667
|
+
rechecks each candidate before cascading deletion. All pruning commands
|
|
668
|
+
preview by default and require administration authorization.
|
|
576
669
|
|
|
577
670
|
## Advisory locks
|
|
578
671
|
|
|
@@ -592,7 +685,7 @@ PostgreSQL transaction-level advisory locks may be used for optional singleton m
|
|
|
592
685
|
| Create actor and allocate message sequence | Instance insert/lock, sequence increment, durable message and ready-membership inserts |
|
|
593
686
|
| Claim activation | Backend claim transaction, generation increment, owner and expiry |
|
|
594
687
|
| Claim next message | Move ready membership to claimed membership conditioned on lease |
|
|
595
|
-
| Successful message commit | Fenced state, durable message result, claimed-membership deletion, effects, reminders, actor outbox, broadcasts |
|
|
688
|
+
| Successful message commit | Fenced state and monotonic revision, durable message result, claimed-membership deletion, effects, reminders, actor outbox, broadcasts |
|
|
596
689
|
| Failed message attempt | Conditional error, claimed deletion, ready reinsertion or dead letter |
|
|
597
690
|
| Renew or release lease | Conditional instance update |
|
|
598
691
|
| Destroy actor | Instance identity lock and cascading delete of state, mailbox, reminders, and outboxes |
|
|
@@ -612,7 +705,7 @@ The semantic guarantees are common, but their coordination implementations diffe
|
|
|
612
705
|
| JSON state | JSONB | JSON | Rails JSON type |
|
|
613
706
|
| Executable-work indexes | Ready/claimed membership tables | Ready/claimed membership tables | Ready/claimed membership tables |
|
|
614
707
|
| Write isolation | Row locks and MVCC | InnoDB row/next-key locks and MVCC | One writer, serializable writes |
|
|
615
|
-
|
|
|
708
|
+
| Sync contention deadline | Transaction lock and statement timeouts | Execution timeout and one-second-granularity InnoDB lock timeout | Busy timeout |
|
|
616
709
|
| Lease clock | Database current time | Database current time | Database current time |
|
|
617
710
|
|
|
618
711
|
All backends use unique identity and sequence constraints, short transactions, and conditional owner/generation/expiry fencing. Passing one backend's suite is not evidence for another.
|
|
@@ -633,14 +726,14 @@ All backends use unique identity and sequence constraints, short transactions, a
|
|
|
633
726
|
12. **How are leases renewed?** Conditional database update by instance, owner, generation, and unexpired lease.
|
|
634
727
|
13. **How does graceful shutdown work?** Stop claims, finish current turn within timeout, release cached leases, stop heartbeat, mark process stopped.
|
|
635
728
|
14. **How does synchronous invocation work across processes?** The caller first tries to claim and execute the actor locally. If another process owns it, a wake-up adapter prompts a durable result query and bounded polling remains the fallback.
|
|
636
|
-
15. **What happens after caller timeout?**
|
|
637
|
-
16. **How are results cleaned up?**
|
|
729
|
+
15. **What happens after caller timeout?** A committed message continues and its eventual result can be recovered with the timeout's authorized message reference. An enqueue timeout leaves no message. Running Ruby code is not preempted.
|
|
730
|
+
16. **How are results cleaned up?** `prune_messages` deletes eligible terminal history in bounded batches after global or per-actor retention. It previews by default and preserves live work, dead letters, retry links, and unfinished outboxes.
|
|
638
731
|
17. **How are large mailboxes managed?** The implemented controls are the per-actor mailbox cap, payload caps, and fair activation yields; rate and global admission controls remain roadmap work.
|
|
639
|
-
18. **How are completed messages pruned?**
|
|
732
|
+
18. **How are completed messages pruned?** Operators schedule the dry-run-reviewed `prune_messages --execute` command. Solid Objects does not run deletion automatically.
|
|
640
733
|
19. **How are state migrations performed?** Explicit one-step actor migrations on activation, persisted only with a successful fenced commit.
|
|
641
734
|
20. **What happens during rolling deploys?** Newer state can make old workers incompatible; deploys must preserve backward readability or drain old workers.
|
|
642
735
|
21. **How are subscriptions authorized?** Verify signed identity, resolve registered type, invoke host authorization, then stream.
|
|
643
|
-
22. **How are lost broadcasts recovered?** Current-state refresh after reconnect; durable outbox retries server delivery.
|
|
736
|
+
22. **How are lost broadcasts recovered?** Current-state scalar replacement and authorized component refresh after reconnect; durable outbox retries server delivery.
|
|
644
737
|
23. **How are actor-to-actor cycles handled?** Synchronous actor waits are rejected; asynchronous request/result messages avoid call-stack cycles.
|
|
645
|
-
24. **Which operations are transactional?** The transaction map above lists every atomic boundary
|
|
738
|
+
24. **Which operations are transactional?** The transaction map above lists every atomic boundary. Actor code and external I/O are outside; registered same-pool commit actions execute inside the fenced state/message transaction.
|
|
646
739
|
25. **Which guarantees depend on PostgreSQL?** None of the public semantics are PostgreSQL-only. PostgreSQL and MySQL depend on row-lock claiming; SQLite depends on serialized write transactions. Each backend's guarantee depends on its adapter-specific integration tests.
|
data/docs/authorization.md
CHANGED
|
@@ -10,14 +10,53 @@ intentionally inert until the host application defines its trust boundary.
|
|
|
10
10
|
| Policy | Gates | Caller context | Risk if opened globally |
|
|
11
11
|
| --- | --- | --- | --- |
|
|
12
12
|
| `authorize_message` | Direct actor methods, explicit `sync` messages, and public `async` enqueue | Value passed as `authorization_context:`; often a user, service principal, or trusted internal marker | Anyone reaching the call site can mutate any known actor identity |
|
|
13
|
-
| `authorize_query` | Attribute reads, declared queries, observable reads, and component
|
|
13
|
+
| `authorize_query` | Attribute reads, declared queries, committed snapshots, scalar observable reads, initial component rendering, and every component refresh dependency | Explicit call context, the context passed to `solid_object`, or the request context resolved for a component refresh | Actor state or personalized projections can leak across users or tenants |
|
|
14
14
|
| `authorize_destroy` | `reference.destroy` | Value passed as `authorization_context:` | Complete actor state, mailbox, reminders, and pending outboxes can be deleted |
|
|
15
15
|
| `authorize_subscription` | Action Cable subscription to one actor stream | The `ActionCable::Connection` object | Clients can receive future observable updates for other actors |
|
|
16
|
-
| `authorize_administration` | Engine administration controllers, process inspection/cleanup, and dead-letter inspection/retry | Rails controller or `{ source: "cli" }` | Operational metadata, arguments, errors, and retries become exposed or mutable |
|
|
16
|
+
| `authorize_administration` | Engine administration controllers, process inspection/cleanup/pruning, message pruning, and dead-letter inspection/retry | Rails controller or `{ source: "cli" }` | Operational metadata, arguments, errors, deletion, and retries become exposed or mutable |
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
Waiting again through `MessageReference#wait` reauthorizes the stored
|
|
19
|
+
invocation as a message or query. Internal reminder, effect-callback, and
|
|
20
|
+
actor-to-actor deliveries come from
|
|
19
21
|
already committed runtime rows and do not re-enter the public client policy.
|
|
20
22
|
|
|
23
|
+
## Realtime authorization contexts
|
|
24
|
+
|
|
25
|
+
Reactive components cross three Rails execution contexts and authorize at all
|
|
26
|
+
three boundaries:
|
|
27
|
+
|
|
28
|
+
1. `solid_object(..., authorization_context:)` uses the explicit Action View
|
|
29
|
+
render context for initial scalar and component reads.
|
|
30
|
+
2. `ActorChannel` passes its authenticated `ActionCable::Connection` to
|
|
31
|
+
`authorize_subscription`.
|
|
32
|
+
3. `ComponentsController` resolves a fresh context for the cookie-bearing HTTP
|
|
33
|
+
request and calls `authorize_query` for the component name and every
|
|
34
|
+
dependency.
|
|
35
|
+
|
|
36
|
+
Configure the refresh resolver when the query policy expects a user or service
|
|
37
|
+
principal rather than the engine controller:
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
SolidObjects.configure do |configuration|
|
|
41
|
+
configuration.component_authorization_context = lambda do |controller:|
|
|
42
|
+
Current.user
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Authentication middleware must populate `Current.user` for the refresh
|
|
48
|
+
request. Do not copy an Action View object or Cable connection into the signed
|
|
49
|
+
token. Those objects are request-specific and the token provides integrity,
|
|
50
|
+
not authorization.
|
|
51
|
+
|
|
52
|
+
Component partials receive the resolved value as the
|
|
53
|
+
`authorization_context` local, allowing two authorized viewers to render
|
|
54
|
+
different projections. Responses use `Cache-Control: private, no-store`.
|
|
55
|
+
Durable outbox rows and shared Cable messages never contain component HTML.
|
|
56
|
+
The stream token also signs the scalar observable targets rendered into that
|
|
57
|
+
specific scope. Component-only dependencies send invalidation metadata but not
|
|
58
|
+
their state value to the browser.
|
|
59
|
+
|
|
21
60
|
## A tenant-aware policy
|
|
22
61
|
|
|
23
62
|
Pass the authenticated user as the call context:
|
|
@@ -80,6 +119,17 @@ Keep `authorize_destroy`, `authorize_subscription`, and
|
|
|
80
119
|
Replace unconditional policies before exposing actor IDs to controllers, API
|
|
81
120
|
clients, MCP tools, jobs carrying user input, or browser subscriptions.
|
|
82
121
|
|
|
122
|
+
For commands executed only on hosts where shell access is already the
|
|
123
|
+
authenticated administration boundary, the generated initializer shows an
|
|
124
|
+
optional CLI-scoped policy:
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
configuration.authorize_administration = lambda do |authorization_context:, **|
|
|
128
|
+
authorization_context.is_a?(Hash) &&
|
|
129
|
+
authorization_context[:source] == "cli"
|
|
130
|
+
end
|
|
131
|
+
```
|
|
132
|
+
|
|
83
133
|
Run `bin/rails solid_objects:doctor` after configuration. Its neutral policy
|
|
84
134
|
probe is deliberately conservative: a context-aware policy may correctly warn
|
|
85
135
|
because it denies a `nil` context.
|
data/docs/benchmarks.md
CHANGED
|
@@ -66,8 +66,8 @@ Attribute reads are therefore not free snapshots from the instance row. They
|
|
|
66
66
|
are ordered durable query messages and grow message history exactly like
|
|
67
67
|
writes.
|
|
68
68
|
|
|
69
|
-
|
|
70
|
-
growth as:
|
|
69
|
+
Built-in pruning previews by default and must be scheduled explicitly. Budget
|
|
70
|
+
message growth before retention as:
|
|
71
71
|
|
|
72
72
|
```text
|
|
73
73
|
daily durable messages = daily actor writes + daily actor reads + daily callbacks
|
|
@@ -76,6 +76,10 @@ daily durable messages = daily actor writes + daily actor reads + daily callback
|
|
|
76
76
|
Review the [retention requirements](operations.md#retention-and-backups) before
|
|
77
77
|
adopting a high-volume surface.
|
|
78
78
|
|
|
79
|
+
Use `reference.snapshot` for an authorized current-state read when mailbox
|
|
80
|
+
ordering is unnecessary. It avoids a message row but can observe state before
|
|
81
|
+
an in-flight turn commits.
|
|
82
|
+
|
|
79
83
|
## Measure the host application
|
|
80
84
|
|
|
81
85
|
Run the adoption benchmark against a dedicated empty database with the same
|
data/docs/correctness.md
CHANGED
|
@@ -101,13 +101,46 @@ The following are atomic:
|
|
|
101
101
|
- activation owner, expiration, and generation acquisition;
|
|
102
102
|
- ready-to-claimed membership move and attempt increment;
|
|
103
103
|
- state, state version, message result/completion, claimed deletion, effects,
|
|
104
|
-
reminders, outbound actor messages,
|
|
104
|
+
same-database commit actions, reminders, outbound actor messages, observable
|
|
105
|
+
broadcasts, and the monotonic actor state revision;
|
|
105
106
|
- failed-attempt record plus ready reinsertion or dead letter;
|
|
106
107
|
- effect completion plus its optional actor outcome message;
|
|
107
108
|
- reminder occurrence enqueue plus reminder advancement; and
|
|
108
109
|
- actor destruction plus cascading removal of all actor-owned rows.
|
|
109
110
|
|
|
110
111
|
Actor Ruby code and external I/O are never inside the actor-state transaction.
|
|
112
|
+
Rails write prevention rejects direct Active Record writes while handlers,
|
|
113
|
+
observables, lifecycle hooks, or state migrations run. A registered commit
|
|
114
|
+
action is the only application-record write path inside the fenced commit, and
|
|
115
|
+
it is available only when Solid Objects and `ActiveRecord::Base` share one
|
|
116
|
+
connection pool. Commit actions must contain only bounded database work.
|
|
117
|
+
External I/O belongs in the effect outbox.
|
|
118
|
+
|
|
119
|
+
## Reactive components
|
|
120
|
+
|
|
121
|
+
A successful fenced turn advances `instances.state_revision` to that message's
|
|
122
|
+
per-actor sequence and inserts changed-observable broadcast rows in the same
|
|
123
|
+
transaction. A rollback, retryable handler failure, or lost activation advances
|
|
124
|
+
neither. Component HTML is not durable and is never placed on the shared actor
|
|
125
|
+
stream.
|
|
126
|
+
|
|
127
|
+
Each component token signs the actor identity, conventional component name,
|
|
128
|
+
explicit dependencies, initial instance ID and revision, and same-origin
|
|
129
|
+
refresh path. The signature detects modification but grants no access. Initial
|
|
130
|
+
rendering invokes query authorization, Cable separately invokes subscription
|
|
131
|
+
authorization, and the cookie-bearing refresh request invokes query
|
|
132
|
+
authorization again for the component name and every dependency.
|
|
133
|
+
|
|
134
|
+
The actor stream token separately signs the scalar observable targets rendered
|
|
135
|
+
into its scope. A component dependency that has no scalar target carries only
|
|
136
|
+
its name and revision over Cable, not its serialized value.
|
|
137
|
+
|
|
138
|
+
Cable compares `(instance_id, state_revision)` pairs, coalesces dependencies
|
|
139
|
+
changed by the same turn, and ignores an older pair after a newer one. A new
|
|
140
|
+
invalidation replaces the whole Turbo Frame generation. A response owned by
|
|
141
|
+
the detached older frame cannot overwrite the current frame. Reconnect
|
|
142
|
+
compares the component's signed initial pair with the current instance row and
|
|
143
|
+
requests the latest committed snapshot when stale.
|
|
111
144
|
|
|
112
145
|
## Synchronous invocation
|
|
113
146
|
|
|
@@ -119,6 +152,17 @@ result. A worker may win the activation instead; the caller then observes the
|
|
|
119
152
|
durable result through wake-up hints with bounded polling as fallback.
|
|
120
153
|
|
|
121
154
|
Timeout raises `SolidObjects::SyncTimeout` but does not cancel the message.
|
|
155
|
+
The exception reports actor identity, message ID and sequence, durable status,
|
|
156
|
+
an earlier mailbox blocker, and activation-owner metadata without exposing
|
|
157
|
+
arguments. Its `message_reference` can reauthorize and wait for the eventual
|
|
158
|
+
result. Adapter lock/query deadlines cover the durable enqueue and coordination
|
|
159
|
+
transactions. If enqueue cannot commit, `SyncEnqueueTimeout` is raised and no
|
|
160
|
+
message reference exists. MySQL lock waits have one-second InnoDB granularity.
|
|
161
|
+
Ruby handlers that already started are not preempted.
|
|
162
|
+
|
|
163
|
+
A synchronous call made while the Solid Objects connection already has an open
|
|
164
|
+
transaction raises `SolidObjects::SyncInsideTransaction` before the message is
|
|
165
|
+
enqueued.
|
|
122
166
|
Destroying the actor while a synchronous caller waits removes its message,
|
|
123
167
|
wakes the caller, and raises `SolidObjects::ActorDestroyed`.
|
|
124
168
|
|
data/docs/database-schema.md
CHANGED
|
@@ -8,9 +8,15 @@ migrations load. No partial indexes are used.
|
|
|
8
8
|
### `instances`
|
|
9
9
|
|
|
10
10
|
One row per `(actor_type, actor_id)`. Stores JSON state, state version,
|
|
11
|
-
next-message sequence, activation
|
|
12
|
-
state, and lifecycle timestamps. The
|
|
13
|
-
process row cannot make two
|
|
11
|
+
monotonic state revision, next-message sequence, activation
|
|
12
|
+
owner/token/expiration/generation, pause state, and lifecycle timestamps. The
|
|
13
|
+
owner/token pairing is constrained so one process row cannot make two
|
|
14
|
+
concurrent activations appear identical.
|
|
15
|
+
|
|
16
|
+
`state_revision` advances to the successful message sequence in the same
|
|
17
|
+
fenced transaction as state and outboxes. Reactive components compare
|
|
18
|
+
`(instance_id, state_revision)` so pruned message history cannot make revisions
|
|
19
|
+
regress and destroy-and-recreate produces a new incarnation.
|
|
14
20
|
|
|
15
21
|
Deleting an instance is the actor-incarnation boundary. Foreign keys cascade
|
|
16
22
|
the delete through messages, ready and claimed memberships, reminders, effects,
|
|
@@ -24,6 +30,11 @@ Indexes:
|
|
|
24
30
|
- owner: dead-process cleanup;
|
|
25
31
|
- last used/ID: retention and reconciliation.
|
|
26
32
|
|
|
33
|
+
Instance expiration is disabled unless its actor type has an explicit
|
|
34
|
+
retention policy. The pruner uses the last-used index for candidate discovery,
|
|
35
|
+
then locks and rechecks that the actor has no owner, mailbox membership,
|
|
36
|
+
scheduled reminder, unresolved outbox, or dead letter before deletion.
|
|
37
|
+
|
|
27
38
|
### `messages`
|
|
28
39
|
|
|
29
40
|
Durable immutable invocation identity and arguments plus sequence, attempt
|
|
@@ -80,8 +91,10 @@ Status/availability/ID drives delivery; completion/ID drives cleanup.
|
|
|
80
91
|
### `broadcasts`
|
|
81
92
|
|
|
82
93
|
Durable observable-change outbox. The unique message/observable key prevents
|
|
83
|
-
duplicate rows for one actor turn.
|
|
84
|
-
|
|
94
|
+
duplicate rows for one actor turn. Rows contain the observable JSON value and
|
|
95
|
+
message/instance references used to derive invalidation metadata, never
|
|
96
|
+
personalized rendered HTML. Claim and delivery indexes support retries and
|
|
97
|
+
cleanup.
|
|
85
98
|
|
|
86
99
|
### `dead_letters`
|
|
87
100
|
|
data/docs/development.md
CHANGED
|
@@ -28,6 +28,46 @@ SOLID_OBJECTS_DATABASE_URL=mysql2://... bundle exec rake test
|
|
|
28
28
|
Each database run must start from an empty dedicated test database because the
|
|
29
29
|
test helper applies the engine migration.
|
|
30
30
|
|
|
31
|
+
## Host application tests
|
|
32
|
+
|
|
33
|
+
Rails transactional tests keep the application connection inside an outer
|
|
34
|
+
transaction. Synchronous actor invocation deliberately rejects that condition
|
|
35
|
+
because nested savepoints retain actor locks until the test transaction ends
|
|
36
|
+
and make durable behavior unlike production.
|
|
37
|
+
|
|
38
|
+
Use an actor-specific base class:
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
require "solid_objects/test_helper"
|
|
42
|
+
|
|
43
|
+
class SolidObjectsTestCase < ActiveSupport::TestCase
|
|
44
|
+
include SolidObjects::TestHelper
|
|
45
|
+
end
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The helper disables transactional tests for that class and removes Solid
|
|
49
|
+
Objects instances and process registrations before and after each test. It
|
|
50
|
+
preserves application configuration, actor registration, and effect/commit
|
|
51
|
+
action registration. If actor commit actions create application records, clean
|
|
52
|
+
those records with fixtures or explicit teardown because they are no longer
|
|
53
|
+
covered by Rails' transaction rollback.
|
|
54
|
+
|
|
55
|
+
Use `drain_solid_objects` to process actor, reminder, effect, callback, and
|
|
56
|
+
broadcast work to a deterministic fixed point without arbitrary sleeps:
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
message = Counter.ref("test").async(:increment)
|
|
60
|
+
|
|
61
|
+
assert_equal 1, drain_solid_objects
|
|
62
|
+
assert_equal "completed", message.status
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Pass `roles: [:actors]` when a test intentionally wants to leave outboxes or
|
|
66
|
+
reminders pending.
|
|
67
|
+
|
|
68
|
+
`SolidObjects::TestHelper.reset_actors!` is also available for explicit suite
|
|
69
|
+
boundaries.
|
|
70
|
+
|
|
31
71
|
## Inline RBS
|
|
32
72
|
|
|
33
73
|
Ruby source starts with:
|
data/docs/fit.md
CHANGED
|
@@ -65,11 +65,19 @@ Each application process that performs synchronous calls registers one caller
|
|
|
65
65
|
process row. Actor state is rewritten as a JSON value on each successful
|
|
66
66
|
mutation.
|
|
67
67
|
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
Built-in bounded pruning is explicit and dry-run by default. Configure global
|
|
69
|
+
and per-actor-type message retention, then schedule the reviewed execute
|
|
70
|
+
commands. Actor instances expire only for types explicitly listed in
|
|
71
|
+
`instance_retention_by_actor_type`. See
|
|
70
72
|
[performance measurements](benchmarks.md) and
|
|
71
73
|
[retention guidance](operations.md#retention-and-backups).
|
|
72
74
|
|
|
75
|
+
Actor handlers may read application records but may not write them directly.
|
|
76
|
+
Use a same-database commit action for a short atomic database change or an
|
|
77
|
+
idempotent effect for external work. If the domain needs broad relational
|
|
78
|
+
updates throughout arbitrary handler code, an ordinary Active Record service
|
|
79
|
+
is likely a clearer fit.
|
|
80
|
+
|
|
73
81
|
## Decision checklist
|
|
74
82
|
|
|
75
83
|
Before adopting an actor, answer:
|
|
@@ -101,6 +101,12 @@ If the actor becomes authoritative before the legacy system is retired, emit a
|
|
|
101
101
|
transactional effect that updates the legacy store. The effect is at least once,
|
|
102
102
|
so the legacy write still needs idempotency.
|
|
103
103
|
|
|
104
|
+
When the legacy state is an application table in the same connection pool as
|
|
105
|
+
Solid Objects, a registered `commit_action` can update it in the fenced actor
|
|
106
|
+
transaction instead. Keep that action database-only and bounded. A separate
|
|
107
|
+
actor database cannot make this atomic; use the outbox effect and reconciliation
|
|
108
|
+
path there.
|
|
109
|
+
|
|
104
110
|
## 5. Cut over in reversible stages
|
|
105
111
|
|
|
106
112
|
A typical zero-downtime sequence is:
|
|
@@ -119,7 +125,8 @@ A typical zero-downtime sequence is:
|
|
|
119
125
|
|
|
120
126
|
Use a feature flag whose rollback restores legacy reads and writes without
|
|
121
127
|
requiring actor deletion. Do not assume a timed-out synchronous actor call did
|
|
122
|
-
not commit;
|
|
128
|
+
not commit; use `error.message_reference.wait` to reauthorize and recover the
|
|
129
|
+
durable result, or use an idempotency key before retrying.
|
|
123
130
|
|
|
124
131
|
## 6. Plan for dormant state and future changes
|
|
125
132
|
|