solid_objects 0.2.0 → 0.3.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.
Files changed (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +23 -0
  3. data/README.md +223 -15
  4. data/benchmark/adoption_latency.rb +5 -0
  5. data/benchmark/support.rb +35 -0
  6. data/docs/architecture.md +62 -15
  7. data/docs/authorization.md +98 -0
  8. data/docs/benchmarks.md +75 -1
  9. data/docs/correctness.md +19 -1
  10. data/docs/database-schema.md +5 -0
  11. data/docs/development.md +45 -4
  12. data/docs/fit.md +98 -0
  13. data/docs/migrating-existing-state.md +140 -0
  14. data/docs/operations.md +94 -4
  15. data/docs/roadmap.md +12 -3
  16. data/docs/security.md +28 -3
  17. data/docs/state-migrations.md +6 -0
  18. data/lib/generators/solid_objects/templates/solid_objects.rb +45 -0
  19. data/lib/solid_objects/activation.rb +33 -4
  20. data/lib/solid_objects/actor.rb +47 -6
  21. data/lib/solid_objects/actor_definition.rb +2 -0
  22. data/lib/solid_objects/actor_snapshot.rb +10 -4
  23. data/lib/solid_objects/application_write_guard.rb +24 -0
  24. data/lib/solid_objects/caller_process.rb +28 -0
  25. data/lib/solid_objects/cli.rb +44 -5
  26. data/lib/solid_objects/client.rb +98 -5
  27. data/lib/solid_objects/commit_action_registry.rb +42 -0
  28. data/lib/solid_objects/configuration.rb +27 -1
  29. data/lib/solid_objects/database_adapter.rb +28 -1
  30. data/lib/solid_objects/database_adapters/mysql.rb +46 -0
  31. data/lib/solid_objects/database_adapters/postgresql.rb +29 -0
  32. data/lib/solid_objects/database_adapters/sqlite.rb +28 -0
  33. data/lib/solid_objects/doctor.rb +311 -0
  34. data/lib/solid_objects/errors.rb +144 -0
  35. data/lib/solid_objects/executor.rb +64 -4
  36. data/lib/solid_objects/instance_pruner.rb +97 -0
  37. data/lib/solid_objects/message_pruner.rb +97 -0
  38. data/lib/solid_objects/message_reference.rb +9 -0
  39. data/lib/solid_objects/process_pruner.rb +49 -0
  40. data/lib/solid_objects/reference.rb +5 -0
  41. data/lib/solid_objects/state_snapshot.rb +41 -0
  42. data/lib/solid_objects/sync_deadline.rb +57 -0
  43. data/lib/solid_objects/sync_diagnostics.rb +133 -0
  44. data/lib/solid_objects/synchronous_invocation.rb +26 -7
  45. data/lib/solid_objects/test_helper.rb +78 -0
  46. data/lib/solid_objects/version.rb +1 -1
  47. data/lib/solid_objects/worker.rb +1 -1
  48. data/lib/solid_objects.rb +34 -0
  49. data/lib/tasks/solid_objects_tasks.rake +10 -0
  50. data/sig/generated/lib/solid_objects/activation.rbs +3 -0
  51. data/sig/generated/lib/solid_objects/actor.rbs +26 -0
  52. data/sig/generated/lib/solid_objects/application_write_guard.rbs +8 -0
  53. data/sig/generated/lib/solid_objects/caller_process.rbs +11 -0
  54. data/sig/generated/lib/solid_objects/cli.rbs +11 -2
  55. data/sig/generated/lib/solid_objects/client.rbs +15 -0
  56. data/sig/generated/lib/solid_objects/commit_action_registry.rbs +43 -0
  57. data/sig/generated/lib/solid_objects/configuration.rbs +27 -7
  58. data/sig/generated/lib/solid_objects/database_adapter.rbs +9 -0
  59. data/sig/generated/lib/solid_objects/database_adapters/mysql.rbs +8 -0
  60. data/sig/generated/lib/solid_objects/database_adapters/postgresql.rbs +8 -0
  61. data/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs +8 -0
  62. data/sig/generated/lib/solid_objects/doctor.rbs +111 -0
  63. data/sig/generated/lib/solid_objects/errors.rbs +118 -0
  64. data/sig/generated/lib/solid_objects/executor.rbs +12 -0
  65. data/sig/generated/lib/solid_objects/instance_pruner.rbs +36 -0
  66. data/sig/generated/lib/solid_objects/message_pruner.rbs +42 -0
  67. data/sig/generated/lib/solid_objects/message_reference.rbs +3 -0
  68. data/sig/generated/lib/solid_objects/process_pruner.rbs +27 -0
  69. data/sig/generated/lib/solid_objects/reference.rbs +3 -0
  70. data/sig/generated/lib/solid_objects/state_snapshot.rbs +30 -0
  71. data/sig/generated/lib/solid_objects/sync_deadline.rbs +31 -0
  72. data/sig/generated/lib/solid_objects/sync_diagnostics.rbs +34 -0
  73. data/sig/generated/lib/solid_objects/synchronous_invocation.rbs +3 -0
  74. data/sig/generated/lib/solid_objects/test_helper.rbs +25 -0
  75. data/sig/generated/lib/solid_objects.rbs +12 -0
  76. metadata +26 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4d95ae7c1b6791ce00951e73eaf535a7b0b357c6708112509544de82eb59314d
4
- data.tar.gz: a6a9ef715c217e38959438d1ab549447e0814c621746967dece190ae46b3108b
3
+ metadata.gz: 48689af6e9b4f08415ddfd549a99d4e7e667e67db5aa4e92bc0052a6d97f51af
4
+ data.tar.gz: 82dc95dc346daf7260356996bfadc99c9c8dd713f2c0fa8c5d8f5efe7859021a
5
5
  SHA512:
6
- metadata.gz: d604e195d678ae5f2ae24932fd0431a7076d0f8258b9e064bbc463042b911512edc7a6b613673b6731256d0b0603311708beb2158d79e421dc85b5febfb37f4d
7
- data.tar.gz: cd817466462c8d0c843fa9766df1f2190ed3b477466661e32a47859f7fc63349327af7cb077d75ab5a43b621bb3ee98e321363b007ff5ad19e311123d871e040
6
+ metadata.gz: d3a4ce2860cca8656919193b2a9bb6b2c2367552653af106db5fd065bd2bf3c7339a543c5b1bc1fdf1bb7d322e88639a6f20cdba2b5cf551b406e629e6d31598
7
+ data.tar.gz: '086652b090e80b0c6830b269e8857ef6de1944b2c2ab145340eeb4a8ba97a26875b9a612c5105ae46bff7362477fe20c3abde837aa4b87d27f5fb91bf19e316e'
data/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 - 2026-08-06
4
+
5
+ - Reject application-record writes from actor handlers and provide registered
6
+ same-database commit actions for fenced atomic changes.
7
+ - Reject synchronous invocation inside an open Solid Objects transaction and
8
+ add adapter database deadlines, durable diagnostics, and recoverable results
9
+ to sync timeouts.
10
+ - Guard handlers, observables, lifecycle hooks, and state migrations from
11
+ direct application-record writes.
12
+ - Add dry-run-first bounded message, process, and opt-in actor-instance
13
+ pruning, configurable retention, and graceful caller-process shutdown.
14
+ - Add authorized committed state snapshots, mutable JSON copies, commit-action
15
+ instrumentation, and deterministic full-runtime Minitest draining.
16
+
17
+ ## 0.2.1 - 2026-08-06
18
+
19
+ - Add `solid_objects:doctor` for configuration, schema, policy, runtime, and
20
+ workerless synchronous round-trip verification.
21
+ - Add onboarding guidance for fit decisions, worker requirements,
22
+ authorization, performance and row growth, retention, Sorbet, RuboCop, and
23
+ migrations from existing state stores.
24
+ - Make the early-Action View engine boot regression explicit.
25
+
3
26
  ## 0.2.0 - 2026-08-06
4
27
 
5
28
  - Make direct actor methods synchronous Durable Object-style RPC.
data/README.md CHANGED
@@ -20,8 +20,14 @@ class Counter < SolidObjects::Actor
20
20
  end
21
21
  end
22
22
 
23
- # from anywhere in your app addressed by name:
24
- Counter.ref("global").increment(amount: 5)
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)
25
31
  ```
26
32
 
27
33
  `Counter / global` is a logical identity. Like a Durable Object named with
@@ -30,12 +36,37 @@ locating a Ruby object. Solid Objects activates it when work arrives, commits
30
36
  its ordered turns one at a time, persists its state, and deactivates it when
31
37
  idle. Different identities can run concurrently.
32
38
 
39
+ The invocation model is the first adoption decision:
40
+
41
+ | Call | Returns | Worker fleet required? |
42
+ | --- | --- | --- |
43
+ | `counter.increment(amount: 5)` | Committed handler result | No |
44
+ | `counter.sync(: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 |
48
+
49
+ Direct methods and `sync` durably enqueue the call, then the Rails caller helps
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.
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
+
60
+ Before adopting a latency-sensitive or high-volume surface, read
61
+ [Is Solid Objects a good fit?](docs/fit.md) and the
62
+ [measured performance and row-growth costs](docs/benchmarks.md).
63
+
33
64
  This is a port of the programming model, not Cloudflare's edge runtime or
34
65
  platform. Read the conceptual overview at [solidobjects.dev](https://solidobjects.dev/)
35
66
  and the exact Rails guarantees in [Correctness and delivery semantics](docs/correctness.md).
36
67
 
37
- Version 0.2 is an early release. Its correctness core is implemented and tested,
38
- 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
39
70
  [Status](#status) and the [roadmap](docs/roadmap.md).
40
71
 
41
72
  ## Table of contents
@@ -43,9 +74,11 @@ but the project does not yet claim production readiness. See
43
74
  - [Cloudflare Durable Objects for Rails](#cloudflare-durable-objects-for-rails)
44
75
  - [Reactive ERB](#reactive-erb)
45
76
  - [Installation](#installation)
77
+ - [Worker requirements](#worker-requirements)
46
78
  - [Defining an actor](#defining-an-actor)
47
79
  - [Actor identity](#actor-identity)
48
80
  - [Invoking an object](#invoking-an-object)
81
+ - [Application database writes](#application-database-writes)
49
82
  - [Effects](#effects)
50
83
  - [Reminders](#reminders)
51
84
  - [Destroying an object](#destroying-an-object)
@@ -171,7 +204,7 @@ refresh from current actor state.
171
204
  `cart.component(:summary)` supports initial rendering of
172
205
  `actors/shopping_cart/_summary`. Durable live component replacement and
173
206
  Turbo append actions are roadmap work; observable replacement is the live path
174
- implemented in 0.2.
207
+ implemented today.
175
208
 
176
209
  Reactive views require `turbo-rails` and a working Action Cable adapter in the
177
210
  host application. They are optional; the actor runtime itself does not depend
@@ -187,10 +220,17 @@ Add the gem, install its initializer and migration, then migrate:
187
220
  bundle add solid_objects
188
221
  bin/rails generate solid_objects:install
189
222
  bin/rails db:migrate
223
+ bin/rails solid_objects:doctor
190
224
  ```
191
225
 
192
- The generated initializer denies all externally initiated operations. Replace
193
- the policy blocks with application-specific authorization before sending
226
+ The doctor validates configuration and required schema shape, reports
227
+ authorization posture and live runtime roles, and completes a real synchronous
228
+ actor round-trip without a worker. It checks required tables and columns instead
229
+ of a copied migration timestamp, which the host application rewrites. It exits
230
+ unsuccessfully when configuration, schema, or the round-trip is broken.
231
+
232
+ The generated initializer is intentionally inert: all five policies deny by
233
+ default. Replace them with application-specific authorization before sending
194
234
  messages, querying state, destroying actors, subscribing to streams, or
195
235
  mounting administration routes:
196
236
 
@@ -205,16 +245,63 @@ end
205
245
  ```
206
246
 
207
247
  Knowledge of an actor ID or signed stream token is never authorization.
248
+ Read the [policy reference and tenant-aware example](docs/authorization.md)
249
+ before opening a policy. Unconditionally allowing message and query calls is
250
+ reasonable only for a controlled server-side pilot. Keep destroy,
251
+ subscription, and administration denied until each has an authenticated
252
+ caller.
253
+
254
+ The engine uses the application's primary Active Record connection by default.
255
+ See [Database support](#database-support) for a separate database configuration.
256
+
257
+ ### Host application tooling
258
+
259
+ Installed engine migrations are copied as
260
+ `db/migrate/*_create_solid_objects_tables.solid_objects.rb`. If the host enables
261
+ `Rails/CreateTableWithTimestamps`, exclude engine-owned migrations rather than
262
+ editing their intentionally specialized hot tables:
263
+
264
+ ```yaml
265
+ Rails/CreateTableWithTimestamps:
266
+ Exclude:
267
+ - "db/migrate/*.solid_objects.rb"
268
+ ```
208
269
 
209
- Start the runtime for asynchronous messages, effects, reminders, and
210
- broadcasts:
270
+ Solid Objects ships inline RBS signatures, not RBI files. Sorbet applications
271
+ can generate the gem RBI with:
272
+
273
+ ```bash
274
+ bundle exec tapioca gem solid_objects
275
+ ```
276
+
277
+ ## Worker requirements
278
+
279
+ Synchronous actors can be adopted without adding a long-running process. Start
280
+ the runtime when the feature introduces asynchronous delivery or outboxes:
281
+
282
+ | Feature | Runtime roles required |
283
+ | --- | --- |
284
+ | Direct actor method or explicit `sync` | None; the caller executes it |
285
+ | Attribute or declared query read | None; the caller executes it |
286
+ | Committed `snapshot` read | None; reads the instance row directly |
287
+ | `destroy` | None |
288
+ | `async` including delayed delivery | Actor worker |
289
+ | One-shot or recurring `schedule` | Reminder scheduler and actor worker |
290
+ | `emit` without an actor callback | Effect worker |
291
+ | `emit` with success or failure callback | Effect worker and actor worker |
292
+ | Actor-to-actor `async` or `send_to` | Effect worker and actor worker |
293
+ | Observable Turbo updates | Broadcast worker, Action Cable, and the actor execution path |
294
+ | Initial `solid_object` server render | No Solid Objects worker; normal Rails rendering |
295
+
296
+ One command starts every Solid Objects role:
211
297
 
212
298
  ```bash
213
299
  bundle exec solid_objects start
214
300
  ```
215
301
 
216
- The engine uses the application's primary Active Record connection by default.
217
- See [Database support](#database-support) for a separate database configuration.
302
+ Deploy and monitor that process before enabling any feature marked as requiring
303
+ a runtime role. A missing worker never makes a durable `async` message
304
+ disappear, but it leaves the message pending indefinitely.
218
305
 
219
306
  ## Defining an actor
220
307
 
@@ -270,6 +357,20 @@ mailbox. State changes must go through public actor methods or explicit
270
357
  State, arguments, results, effects, and reminder arguments accept
271
358
  JSON-compatible values. Solid Objects never deserializes Ruby `Marshal` data.
272
359
 
360
+ Attribute readers are ordered mailbox queries and retain message history. For
361
+ a read that does not need mailbox ordering, use an authorized committed
362
+ snapshot:
363
+
364
+ ```ruby
365
+ snapshot = cart.snapshot
366
+ items = snapshot.items
367
+ ```
368
+
369
+ Snapshots and synchronous results are deeply frozen. Use
370
+ `SolidObjects.mutable_copy(items)` before changing a returned collection.
371
+ Snapshot reads can race with an in-flight turn; they return the most recently
372
+ committed state and do not create or activate a missing actor.
373
+
273
374
  Lifecycle hooks are also available:
274
375
 
275
376
  ```ruby
@@ -375,6 +476,37 @@ and MCP request/response boundaries when the handler itself fits the
375
476
  application's latency budget. If another process owns the activation, the
376
477
  caller waits for the durable result using wake-up hints with bounded database
377
478
  polling as the fallback. A timeout never cancels the durable invocation.
479
+ `SolidObjects::SyncTimeout` includes actor identity, message ID, sequence,
480
+ durable status, mailbox blocker, and activation-owner diagnostics without
481
+ including message arguments. The configured timeout also bounds adapter
482
+ database lock waits from the enqueue attempt through result observation.
483
+ PostgreSQL uses transaction lock and statement timeouts, SQLite uses its busy
484
+ timeout, and MySQL uses its execution timeout plus InnoDB's one-second minimum
485
+ lock-wait granularity.
486
+
487
+ The durable call can finish after its original caller gives up. Reauthorize and
488
+ recover its eventual result through the durable message identity:
489
+
490
+ ```ruby
491
+ begin
492
+ order.submit(timeout: 250.milliseconds)
493
+ rescue SolidObjects::SyncTimeout => error
494
+ result = error.message_reference.wait(
495
+ timeout: 5.seconds,
496
+ authorization_context: Current.user
497
+ )
498
+ end
499
+ ```
500
+
501
+ If the enqueue transaction itself cannot finish within the budget, Solid
502
+ Objects raises `SyncEnqueueTimeout`; no durable message exists to recover.
503
+ Timeouts do not preempt Ruby handler code that has already started.
504
+
505
+ Do not wrap a synchronous actor call in `ApplicationRecord.transaction`.
506
+ Solid Objects raises `SolidObjects::SyncInsideTransaction` before enqueue when
507
+ its connection already has an open transaction. Move the actor call before the
508
+ transaction, use `async`, or let the actor own the coordinated change through a
509
+ commit action.
378
510
 
379
511
  Actor code cannot use direct calls or `sync` on another actor; synchronous
380
512
  actor-to-actor waits can deadlock in cycles. Use `async` or `send_to` and a
@@ -396,6 +528,10 @@ The caller receives `SolidObjects::Rejected` with a stable code, message, and
396
528
  JSON-compatible details. The rejected message remains durable for audit, actor
397
529
  state is rolled back, and no later mailbox turn is blocked.
398
530
 
531
+ `Rejected#code` is a `String`, even when `reject` receives a symbol. Codes must
532
+ match `\A[a-z][a-z0-9_]*\z`; invalid codes raise `ArgumentError` when the
533
+ handler calls `reject`.
534
+
399
535
  ### Redelivery
400
536
 
401
537
  Sequential does not mean once. A handler can run again after a process crash or
@@ -412,6 +548,50 @@ end
412
548
 
413
549
  External systems must also deduplicate effects using the stable effect ID.
414
550
 
551
+ ## Application database writes
552
+
553
+ Actor handlers execute outside the fenced commit. They may query application
554
+ records, but Solid Objects rejects direct Active Record writes from all
555
+ user-supplied actor code: handlers, observables, activation/deactivation hooks,
556
+ and state migrations. Otherwise an application row could commit before the
557
+ actor later raises or loses its activation fence.
558
+
559
+ For a short database-only change that must commit atomically with actor state,
560
+ stage a named action:
561
+
562
+ ```ruby
563
+ class Assessment < SolidObjects::Actor
564
+ attribute :status, default: "open"
565
+
566
+ def finish(attempt_id:, score:)
567
+ self.status = "complete"
568
+ commit_action :complete_attempt, attempt_id:, score:
569
+ end
570
+ end
571
+ ```
572
+
573
+ Register its implementation during application boot:
574
+
575
+ ```ruby
576
+ SolidObjects.register_commit_action(:complete_attempt) do |arguments, context|
577
+ AssessmentAttempt.find(arguments.fetch("attempt_id")).update!(
578
+ score: arguments.fetch("score"),
579
+ actor_message_id: context.message_id
580
+ )
581
+ end
582
+ ```
583
+
584
+ The registered block runs inside the short fenced transaction. Its database
585
+ writes, actor state, message completion, and outboxes all commit or roll back
586
+ together. Commit actions require Solid Objects and `ActiveRecord::Base` to
587
+ share one connection pool. They may be invoked again after a database rollback,
588
+ so keep them deterministic, bounded, and database-only. Never perform network
589
+ I/O, wait for another actor, or enqueue nontransactional work from a commit
590
+ action.
591
+
592
+ When Solid Objects uses a separate actor database, use `emit` and an idempotent
593
+ effect consumer instead; the two databases cannot share one transaction.
594
+
415
595
  ## Effects
416
596
 
417
597
  Cloudflare Durable Objects can call external services directly. Solid Objects
@@ -475,6 +655,10 @@ It may read `SolidObjects::Instance.states_for`, `.without_pending_work`, and
475
655
  `.orphaned`, but every repair must go through `async`. Never bulk-update actor
476
656
  state around the lease and fencing checks.
477
657
 
658
+ Suspended actors should be reported rather than silently resumed. Spread large
659
+ repair batches with `available_at:` so reconciliation cannot stampede one
660
+ mailbox or the worker fleet.
661
+
478
662
  ## Destroying an object
479
663
 
480
664
  Destroy an actor incarnation through its reference:
@@ -551,6 +735,11 @@ Important defaults:
551
735
  | `max_attempts` | 5 |
552
736
  | `process_heartbeat_interval` | 15 seconds |
553
737
  | `process_alive_threshold` | 60 seconds |
738
+ | `message_retention` | 30 days |
739
+ | `message_retention_by_actor_type` | `{}` |
740
+ | `instance_retention_by_actor_type` | `{}`; instances never expire unless listed |
741
+ | `process_retention` | 7 days |
742
+ | `prune_batch_size` | 1,000 |
554
743
  | `worker_count` | 1 |
555
744
  | `effect_worker_count` | 1 |
556
745
  | `broadcast_worker_count` | 1 |
@@ -584,10 +773,16 @@ Administration commands require the administration policy:
584
773
  ```bash
585
774
  bundle exec solid_objects status
586
775
  bundle exec solid_objects cleanup
776
+ bundle exec solid_objects prune_messages
777
+ bundle exec solid_objects prune_instances
778
+ bundle exec solid_objects prune_processes
587
779
  bundle exec solid_objects dead_letters
588
780
  bundle exec solid_objects retry_dead_letter 123
589
781
  ```
590
782
 
783
+ The prune commands preview counts by default. Add `--execute` only after
784
+ reviewing the configured retention policy.
785
+
591
786
  The supervisor stops new claims, drains active loops, releases cached leases,
592
787
  and marks process rows stopped on graceful shutdown. A hard-killed worker's
593
788
  claimed turn is recovered after its process heartbeat or activation lease
@@ -686,6 +881,13 @@ Do not use it for stateless work, bulk pipelines, CPU-heavy computation,
686
881
  cross-actor transactions, slow network calls inside handlers, or domains that
687
882
  are clearer as normalized Active Record models and direct service objects.
688
883
 
884
+ High-QPS request reads, rate-limit counters, impression pipelines, large JSON
885
+ documents, and latency budgets that cannot tolerate several coordination
886
+ transactions are explicit anti-patterns. Read the full
887
+ [fit and anti-pattern guide](docs/fit.md) before migrating an existing
888
+ surface, and use the [legacy-state migration cookbook](docs/migrating-existing-state.md)
889
+ for staged cutovers.
890
+
689
891
  ## Comparisons
690
892
 
691
893
  | Tool | What Solid Objects adds or changes |
@@ -727,11 +929,13 @@ See the [development guide](docs/development.md) and
727
929
 
728
930
  ## Status
729
931
 
730
- Implemented and tested in 0.2:
932
+ Implemented and tested in 0.3:
731
933
 
732
934
  - Rails engine, install generator, migrations, and `solid_objects` executable;
733
935
  - actor registry, references, JSON state, and state migrations;
734
936
  - direct synchronous actor RPC, explicit `sync`, and durable `async`;
937
+ - guarded transaction boundaries, same-database commit actions, adapter lock
938
+ deadlines, structured synchronous timeout diagnostics, and result recovery;
735
939
  - durable message history plus ready and claimed membership tables;
736
940
  - concurrent sequence allocation and actor creation;
737
941
  - activation leases, per-activation tokens, fencing generations, and
@@ -744,7 +948,11 @@ Implemented and tested in 0.2:
744
948
  - authorized actor destruction with fenced stale-write rejection and cascading
745
949
  durable-work cleanup;
746
950
  - durable observable broadcasts and authorized Action Cable refresh;
747
- - process registration, heartbeats, cleanup, and graceful shutdown; and
951
+ - process registration, heartbeats, caller shutdown, cleanup, and bounded
952
+ message/process retention plus opt-in actor-instance expiration;
953
+ - an opt-in Minitest helper for actor-state isolation and deterministic async
954
+ actor/reminder/effect/broadcast draining;
955
+ - authorized mailbox-free state snapshots and mutable JSON copies; and
748
956
  - SQLite, PostgreSQL, and MySQL integration tests.
749
957
 
750
958
  Partially implemented:
@@ -757,8 +965,8 @@ Partially implemented:
757
965
  Turbo append actions remain future work;
758
966
  - local admission limits exist, but distributed rate limits and global
759
967
  admission control do not; and
760
- - administration views exist, but retention automation and richer audit tools
761
- do not.
968
+ - administration views and pruning commands exist, but scheduled maintenance
969
+ and richer audit tools do not.
762
970
 
763
971
  Production readiness requires hardening and operational soak evidence. The
764
972
  [roadmap](docs/roadmap.md) tracks that work.
@@ -0,0 +1,5 @@
1
+ # rbs_inline: enabled
2
+
3
+ require_relative "support"
4
+
5
+ SolidObjectsBenchmark.adoption_latency
data/benchmark/support.rb CHANGED
@@ -145,6 +145,32 @@ module SolidObjectsBenchmark
145
145
  "p99=#{milliseconds(percentile(sorted, 0.99))}ms"
146
146
  end
147
147
 
148
+ # @rbs () -> void
149
+ def adoption_latency
150
+ instance_count = SolidObjects::Instance.count
151
+ message_count = SolidObjects::Message.count
152
+
153
+ cold_elapsed = Benchmark.realtime do
154
+ CounterActor.ref("adoption-cold").increment
155
+ end
156
+
157
+ reference = CounterActor.ref("adoption-warm")
158
+ reference.increment
159
+ write_samples = Array.new(count) do
160
+ Benchmark.realtime { reference.increment }
161
+ end
162
+ read_samples = Array.new(count) do
163
+ Benchmark.realtime { reference.count }
164
+ end
165
+
166
+ puts "first cold call: #{milliseconds(cold_elapsed)}ms"
167
+ puts latency_summary("warm writes", write_samples)
168
+ puts latency_summary("ordered reads", read_samples)
169
+ puts "durable row growth: " \
170
+ "instances=+#{SolidObjects::Instance.count - instance_count}, " \
171
+ "messages=+#{SolidObjects::Message.count - message_count}"
172
+ end
173
+
148
174
  # @rbs () -> void
149
175
  def activation_cache
150
176
  reference = CounterActor.ref("cache")
@@ -255,6 +281,15 @@ module SolidObjectsBenchmark
255
281
  samples.fetch(((samples.length - 1) * fraction).ceil)
256
282
  end
257
283
 
284
+ # @rbs (String, Array[Float]) -> String
285
+ def latency_summary(name, samples)
286
+ sorted = samples.sort
287
+ "#{name} #{samples.length} calls: " \
288
+ "median=#{milliseconds(percentile(sorted, 0.50))}ms " \
289
+ "min=#{milliseconds(sorted.first)}ms " \
290
+ "max=#{milliseconds(sorted.last)}ms"
291
+ end
292
+
258
293
  # @rbs (Float) -> String
259
294
  def milliseconds(seconds)
260
295
  format("%.1f", seconds * 1_000)
data/docs/architecture.md CHANGED
@@ -150,6 +150,12 @@ snapshots are deeply frozen. Use `async` for durable fire-and-forget delivery
150
150
  and `sync` for dynamic operation names. The explicit `message` DSL remains
151
151
  available for dynamic definitions.
152
152
 
153
+ `reference.snapshot` is the explicit unordered read path. It invokes query
154
+ authorization, reads the most recently committed instance state without
155
+ creating a message or activation, applies required state migrations in memory,
156
+ and returns deeply frozen declared attributes. It can race with an in-flight
157
+ turn. `SolidObjects.mutable_copy` creates an independent mutable JSON value.
158
+
153
159
  `message` and `query` both execute as durable mailbox turns. A query may not
154
160
  mutate state. The executor detects query mutation and fails the message. An
155
161
  observable is a named projection of state used by server rendering and realtime
@@ -161,6 +167,8 @@ Lifecycle hooks are deterministic local hooks:
161
167
  - `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
168
 
163
169
  Durable application cleanup belongs in messages, reminders, or effects.
170
+ Handlers, observable blocks, lifecycle hooks, and state migrations all execute
171
+ with application Active Record writes prevented.
164
172
 
165
173
  ## Enqueue and sequence allocation
166
174
 
@@ -248,6 +256,8 @@ Actor code then executes with no open database transaction and no pinned connect
248
256
 
249
257
  - Read and mutate its in-memory state for a message
250
258
  - Read state for a query
259
+ - Read application records
260
+ - Stage same-database commit actions
251
261
  - Stage effects
252
262
  - Stage reminders
253
263
  - Stage asynchronous actor messages
@@ -258,8 +268,16 @@ It cannot:
258
268
  - Perform a synchronous actor-to-actor wait
259
269
  - Assume execution happens once
260
270
  - Commit actor state directly
271
+ - Write application records directly
272
+ - Perform external I/O that must be atomic with actor state
261
273
 
262
- After actor code, the executor validates state and staged data as JSON and computes changed observables.
274
+ Rails write prevention turns a direct Active Record write into
275
+ `ApplicationWriteForbidden` before it reaches the database. Handler and
276
+ observable failures become nonretryable message failures. Activation and
277
+ migration failures happen before the message is claimed. A deactivation-hook
278
+ failure is instrumented and logged, but cannot replace an already committed
279
+ result or prevent best-effort lease release. After actor code, the executor
280
+ validates state and staged data as JSON and computes changed observables.
263
281
 
264
282
  ## Fenced commit
265
283
 
@@ -268,14 +286,15 @@ Successful completion uses one database transaction:
268
286
  1. Lock the instance row.
269
287
  2. Verify owner, activation token, generation, and an unexpired lease using database time.
270
288
  3. Lock the durable message and verify its claimed membership belongs to that owner, activation token, and generation.
271
- 4. Update native JSON state and state version.
272
- 5. Store the completion timestamp and result on the durable message and delete claimed membership.
273
- 6. Insert staged effects.
274
- 7. Insert or update staged reminders.
275
- 8. Insert staged actor-message outbox rows.
276
- 9. Insert changed-observable broadcast rows.
277
- 10. Update actor last-used time.
278
- 11. Commit.
289
+ 4. Execute registered same-database commit actions.
290
+ 5. Update native JSON state and state version.
291
+ 6. Store the completion timestamp and result on the durable message and delete claimed membership.
292
+ 7. Insert staged effects.
293
+ 8. Insert or update staged reminders.
294
+ 9. Insert staged actor-message outbox rows.
295
+ 10. Insert changed-observable broadcast rows.
296
+ 11. Update actor last-used time.
297
+ 12. Commit.
279
298
 
280
299
  Any lease or message predicate failure raises `LostActivation` and rolls back every item. The stale worker discards its in-memory activation.
281
300
 
@@ -346,11 +365,25 @@ overhead at or below 100 milliseconds; polling fallback can pay up to
346
365
  Caller timeout:
347
366
 
348
367
  - Raises `SolidObjects::SyncTimeout`.
368
+ - Reports durable message, blocker, and activation-owner diagnostics.
369
+ - Exposes a `message_reference` that can reauthorize and wait again.
349
370
  - Does not cancel or delete the message.
350
371
  - Does not prevent later execution.
351
372
  - Leaves the result available until retention cleanup.
352
373
 
353
- The durable row remains after timeout and can be inspected directly by message ID. A public request-ID lookup and bounded retention commands are not yet implemented.
374
+ The configured timeout begins before durable enqueue. PostgreSQL transaction
375
+ lock and statement timeouts, MySQL execution and InnoDB lock-wait timeouts, and
376
+ SQLite busy timeout bound database contention. MySQL rounds lock waits up to
377
+ its one-second minimum. If enqueue cannot commit, `SyncEnqueueTimeout` is
378
+ raised and no durable result exists. Once Ruby handler code starts, it is not
379
+ safely preempted and may outlive the caller budget.
380
+
381
+ The durable row remains after a normal wait timeout and can be recovered with
382
+ `error.message_reference.wait`. Bounded retention commands are implemented;
383
+ public request-ID lookup is not. A sync call made with an already-open
384
+ transaction on the Solid Objects connection raises `SyncInsideTransaction`
385
+ before enqueue. This avoids savepoint enlistment, lock retention until the
386
+ outer commit, and callers timing out on work they indirectly block.
354
387
 
355
388
  `async` performs the same durable enqueue without caller assistance or result
356
389
  waiting and immediately returns a `MessageReference`. Runtime workers process
@@ -505,6 +538,7 @@ end
505
538
  ```
506
539
 
507
540
  Activation applies each step in order. Missing steps, cycles, non-JSON output, or stored versions newer than code fail activation.
541
+ Migration code is subject to the same application-write guard as handlers.
508
542
 
509
543
  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
544
 
@@ -573,6 +607,19 @@ An activation becomes idle when it has no due earliest message and no turn in fl
573
607
  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
608
 
575
609
  `on_deactivate` is best effort and nondurable. It may not run on crash and cannot be the source of a correctness requirement.
610
+ A failing hook is logged and instrumented as
611
+ `solid_objects.activation.deactivation_failed`; lease release is still
612
+ attempted and a committed synchronous result is preserved.
613
+
614
+ ## Retention
615
+
616
+ Terminal messages and stopped process rows have configurable bounded pruning.
617
+ Actor instances never expire by default. Types listed in
618
+ `instance_retention_by_actor_type` become eligible after their idle cutoff only
619
+ when they are unowned, unpaused, and have no ready/claimed work, scheduled
620
+ reminder, unfinished or dead outbox, or dead letter. The pruner locks and
621
+ rechecks each candidate before cascading deletion. All pruning commands
622
+ preview by default and require administration authorization.
576
623
 
577
624
  ## Advisory locks
578
625
 
@@ -612,7 +659,7 @@ The semantic guarantees are common, but their coordination implementations diffe
612
659
  | JSON state | JSONB | JSON | Rails JSON type |
613
660
  | Executable-work indexes | Ready/claimed membership tables | Ready/claimed membership tables | Ready/claimed membership tables |
614
661
  | Write isolation | Row locks and MVCC | InnoDB row/next-key locks and MVCC | One writer, serializable writes |
615
- | Contention retry | Database/Active Record behavior; explicit classification is roadmap | Database/Active Record behavior; explicit classification is roadmap | Busy timeout; explicit busy classification is roadmap |
662
+ | Sync contention deadline | Transaction lock and statement timeouts | Execution timeout and one-second-granularity InnoDB lock timeout | Busy timeout |
616
663
  | Lease clock | Database current time | Database current time | Database current time |
617
664
 
618
665
  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 +680,14 @@ All backends use unique identity and sequence constraints, short transactions, a
633
680
  12. **How are leases renewed?** Conditional database update by instance, owner, generation, and unexpired lease.
634
681
  13. **How does graceful shutdown work?** Stop claims, finish current turn within timeout, release cached leases, stop heartbeat, mark process stopped.
635
682
  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?** The durable message continues and its eventual result remains on the message row.
637
- 16. **How are results cleaned up?** The schema has cleanup indexes, but bounded retention tooling is not implemented yet.
683
+ 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.
684
+ 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
685
  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?** Cleanup indexes support future bounded pruning; the initial release does not automatically prune them.
686
+ 18. **How are completed messages pruned?** Operators schedule the dry-run-reviewed `prune_messages --execute` command. Solid Objects does not run deletion automatically.
640
687
  19. **How are state migrations performed?** Explicit one-step actor migrations on activation, persisted only with a successful fenced commit.
641
688
  20. **What happens during rolling deploys?** Newer state can make old workers incompatible; deploys must preserve backward readability or drain old workers.
642
689
  21. **How are subscriptions authorized?** Verify signed identity, resolve registered type, invoke host authorization, then stream.
643
690
  22. **How are lost broadcasts recovered?** Current-state refresh after reconnect; durable outbox retries server delivery.
644
691
  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; actor code and I/O are outside.
692
+ 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
693
  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.