solid_objects 0.16.1 → 0.17.1

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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +100 -0
  3. data/README.md +9 -0
  4. data/Rakefile +5 -0
  5. data/app/models/solid_objects/message.rb +15 -0
  6. data/docs/agents.md +253 -0
  7. data/docs/architecture.md +16 -6
  8. data/docs/authorization.md +10 -3
  9. data/docs/observability.md +186 -0
  10. data/docs/operations.md +10 -8
  11. data/docs/realtime.md +4 -3
  12. data/docs/reminders.md +3 -1
  13. data/docs/roadmap.md +17 -1
  14. data/docs/security.md +2 -2
  15. data/docs/transmission.md +17 -6
  16. data/docs/virtual-actors.md +226 -0
  17. data/examples/quickstart/README.md +246 -0
  18. data/examples/quickstart/app/actors/ticket_sale.rb +22 -0
  19. data/examples/quickstart/smoke.rb +421 -0
  20. data/lib/solid_objects/activation.rb +6 -3
  21. data/lib/solid_objects/actor.rb +29 -2
  22. data/lib/solid_objects/actor_channel.rb +9 -1
  23. data/lib/solid_objects/actor_snapshot.rb +14 -8
  24. data/lib/solid_objects/broadcast_executor.rb +1 -0
  25. data/lib/solid_objects/client.rb +15 -1
  26. data/lib/solid_objects/configuration.rb +4 -0
  27. data/lib/solid_objects/database_adapters/sqlite.rb +7 -1
  28. data/lib/solid_objects/diagnostics.rb +83 -0
  29. data/lib/solid_objects/effect_executor.rb +4 -0
  30. data/lib/solid_objects/errors.rb +3 -0
  31. data/lib/solid_objects/executor.rb +34 -16
  32. data/lib/solid_objects/instrumentation.rb +22 -1
  33. data/lib/solid_objects/log_subscriber.rb +1 -1
  34. data/lib/solid_objects/mailbox.rb +5 -1
  35. data/lib/solid_objects/message_reference.rb +9 -9
  36. data/lib/solid_objects/observer_registry.rb +47 -0
  37. data/lib/solid_objects/payload_broadcast.rb +9 -8
  38. data/lib/solid_objects/reference.rb +19 -0
  39. data/lib/solid_objects/reminder_scheduler.rb +5 -0
  40. data/lib/solid_objects/state_snapshot.rb +1 -0
  41. data/lib/solid_objects/supervisor.rb +3 -6
  42. data/lib/solid_objects/synchronous_invocation.rb +1 -21
  43. data/lib/solid_objects/telemetry.rb +137 -0
  44. data/lib/solid_objects/version.rb +1 -1
  45. data/lib/solid_objects/wake_up_adapters/postgresql.rb +1 -2
  46. data/lib/solid_objects/wake_up_adapters/redis.rb +1 -2
  47. data/lib/solid_objects/worker.rb +1 -2
  48. data/lib/solid_objects.rb +10 -0
  49. data/sig/generated/lib/solid_objects/actor.rbs +9 -0
  50. data/sig/generated/lib/solid_objects/actor_channel.rbs +3 -0
  51. data/sig/generated/lib/solid_objects/actor_snapshot.rbs +7 -2
  52. data/sig/generated/lib/solid_objects/client.rbs +3 -0
  53. data/sig/generated/lib/solid_objects/configuration.rbs +7 -3
  54. data/sig/generated/lib/solid_objects/diagnostics.rbs +25 -0
  55. data/sig/generated/lib/solid_objects/errors.rbs +3 -0
  56. data/sig/generated/lib/solid_objects/executor.rbs +10 -2
  57. data/sig/generated/lib/solid_objects/message_reference.rbs +6 -6
  58. data/sig/generated/lib/solid_objects/observer_registry.rbs +29 -0
  59. data/sig/generated/lib/solid_objects/payload_broadcast.rbs +2 -4
  60. data/sig/generated/lib/solid_objects/reference.rbs +9 -0
  61. data/sig/generated/lib/solid_objects/synchronous_invocation.rbs +0 -3
  62. data/sig/generated/lib/solid_objects/telemetry.rbs +30 -0
  63. data/sig/generated/lib/solid_objects.rbs +3 -0
  64. data/sig/generated/models/solid_objects/message.rbs +3 -0
  65. data/sig/public/json_value.rbs +3 -0
  66. data/sig/public/telemetry.rbs +49 -0
  67. data/sig/support/framework.rbs +5 -0
  68. metadata +24 -9
@@ -0,0 +1,186 @@
1
+ # Portable observability
2
+
3
+ Configure `instrumentation(event)` to receive structured events. No exporter SDK
4
+ is required. The same JSON envelope is emitted by Ruby, SQLite, PostgreSQL, MySQL,
5
+ and the Durable Objects host. Existing JavaScript `name`, `occurredAt`, and
6
+ `attributes` fields remain available. Ruby's Active Support notifications remain
7
+ available with their existing snake_case payloads.
8
+
9
+ ```ruby
10
+ SolidObjects.configure do |configuration|
11
+ configuration.instrumentation = ->(event) do
12
+ Rails.logger.info(JSON.generate(event))
13
+ end
14
+ end
15
+ ```
16
+
17
+ JavaScript passes `instrumentation` to `configure()`. Ruby sets
18
+ `configuration.instrumentation` inside `SolidObjects.configure`.
19
+
20
+ ## Schema version 1
21
+
22
+ Every event has `schemaVersion`, `name` (prefixed with `solid_objects.`),
23
+ `occurredAt` (UTC ISO 8601), `adapter`, `actorType`, `actorId`, `incarnation`,
24
+ `revision`, `messageId`, `attempt`, `attributes`, and `metrics`.
25
+ Unavailable identifiers are null; `attempt` is zero outside a message attempt.
26
+ Ruby publishes the RBS types `SolidObjects::portable_event`,
27
+ `SolidObjects::actor_diagnostics`, and `SolidObjects::event_observer`. JavaScript
28
+ exports `InstrumentationEvent`, `ActorDiagnostics`, and `EventObserver`.
29
+ An incarnation identifies a persisted actor instance, independently of its lease
30
+ generation. Revisions and IDs are strings. Process-wide events have null actor
31
+ identity. A message ID or revision correlates actor work where applicable.
32
+
33
+ Only known scalar metadata fields enter the portable envelope. Arguments, state,
34
+ results, credentials, backtraces, exception text, nested provider data, and unknown
35
+ attributes are excluded. Actor IDs remain correlation data: applications should
36
+ use opaque actor identifiers and apply their own retention policy to event logs.
37
+ Events and metric samples are immutable. Throwing observers, rejected observer
38
+ promises, and a failing instrumentation error logger cannot change a turn's
39
+ result. When an exporter or observer raises, both runtimes log
40
+ `solid_objects.instrumentation.failed` with the event name and the error class.
41
+ Delivery is best effort and synchronous callbacks should be short;
42
+ JavaScript does not await exporters. Telemetry is not a durable audit trail.
43
+
44
+ | Event | Meaning |
45
+ | ------------------------------------------- | ---------------------------------------------------------------------------- |
46
+ | `activation.started/completed/failed` | Local actor activation hook lifecycle |
47
+ | `message.started/completed/failed/rejected` | One attempt's execution outcome |
48
+ | `message.retry` | Failed attempt durably queued for another attempt |
49
+ | `dead_letter.created` | Message exhausted retries or failed permanently |
50
+ | `commit_action.started/completed/failed` | One registered commit action inside the commit transaction |
51
+ | `mailbox.depth` | On-demand diagnostic sample; `depth` is null when the sample is truncated |
52
+ | `reminder.enqueued` | Due reminder dispatch; lateness is measured from due time |
53
+ | `outbox.age` | Delivery observation; age is time since the item's current availability time |
54
+ | `recovery.reclaimed` | A previously claimed, interrupted message begins another attempt |
55
+ | `recovery.completed/failed` | Durable effect recovery callback commits or enters the dead-letter queue |
56
+ | `snapshot.read` | Authorized snapshot constructed without exposing its contents |
57
+ | `realtime.connected/disconnected` | Actor subscription added or removed |
58
+ | `payload_broadcast.failed` | One personalized payload failed; `payload` names it |
59
+
60
+ Events describe local observations. Concurrent deletion, crashes, and failed
61
+ exporters can omit events. Never infer exactly-once delivery from event counts.
62
+ Additional existing runtime events retain their names.
63
+
64
+ ## Event attributes
65
+
66
+ `compatibility/telemetry-events.json` holds the attribute allowlist and the exact
67
+ attribute keys of each core event. Both test suites compare the events of the SQL
68
+ runtimes with this file. Message events carry `operation` and `deliveryMode`.
69
+ `message.failed` also carries a boolean `retryable` and an `outcome` of
70
+ `retrying` or `dead`. Commit action events carry `commitAction` and
71
+ `activationGeneration`. Activation events carry `generation` and `ownerId`.
72
+
73
+ Ruby activates an actor instance before it claims a message, so its activation
74
+ events have no message fields. JavaScript activates an actor in the turn that
75
+ claims a message, so its activation events also carry the `messageId`,
76
+ `requestId`, `sequence`, `attempt`, `operation`, and `deliveryMode` of that
77
+ message. The contract file records these keys as JavaScript-only.
78
+
79
+ The Durable Objects host sends the same envelope, but some of its events carry
80
+ fewer attributes. The contract file does not apply to that host.
81
+
82
+ Portable `polling.interval_changed` events carry `previousIntervalMilliseconds`,
83
+ `currentIntervalMilliseconds`, and a string `reason`. Ruby converts its native
84
+ second-based notification values while preserving the original notification.
85
+
86
+ ## Synchronous timeout diagnostics
87
+
88
+ `solid_objects.sync.timeout` includes `waitingOn`, `activationOwnerId`, and
89
+ `activationGeneration` in `attributes`. Generations are decimal strings;
90
+ unavailable activation fields are null. Both runtimes use the same wait reasons:
91
+ `actorPaused`, `activationHeld`, `earlierMessage`, `messageClaimed`,
92
+ `notYetAvailable`, `readyUnclaimed`, `databaseContention`, and `unknown`.
93
+ Ruby's exception attributes and Active Support notifications retain their native
94
+ snake_case names and reason values. The portable instrumentation envelope uses
95
+ the shared camelCase contract. The Durable Objects host does not send
96
+ `sync.timeout`. A `call` timeout there reports `waitingOn: "unknown"`.
97
+
98
+ ## Metrics and tracing
99
+
100
+ Metrics are sample descriptions. Exporting them is opt-in: the runtime does not
101
+ register meters, allocate per-actor metric series, or install a vendor SDK.
102
+
103
+ | Name | Kind | Unit | Aggregation |
104
+ | --------------------------------- | --------- | ---- | ------------------------------------------------------ |
105
+ | `solid_objects.events` | counter | `1` | Sum one per event |
106
+ | `solid_objects.duration` | histogram | `ms` | Distribution of observed attempt duration |
107
+ | `solid_objects.reminder.lateness` | histogram | `ms` | Distribution of reminder dispatch delay |
108
+ | `solid_objects.outbox.age` | histogram | `ms` | Distribution of delivery delay since availability |
109
+ | `solid_objects.mailbox.depth` | gauge | `1` | Last exact sampled actor depth; omit truncated samples |
110
+
111
+ Labels contain only event name, adapter family, and declared actor type. Keep the
112
+ actor type registry finite. Never add actor ID, incarnation, message ID, operation
113
+ arguments, request IDs, or error text to metric labels. A gauge without an actor
114
+ label represents the most recently observed actor; it is not total fleet backlog.
115
+ For tracing, correlate start/outcome events using `adapter`, `incarnation`,
116
+ `messageId`, and `attempt`, and close or expire spans when no outcome arrives.
117
+
118
+ ## Actor observers and diagnostics
119
+
120
+ ```ruby
121
+ cart = ShoppingCart.ref("demo-cart")
122
+ stop = cart.observe(authorization_context: operator) do |event|
123
+ logger.info(event.to_json)
124
+ end
125
+ summary = cart.diagnostics(authorization_context: operator, limit: 50)
126
+ stop.call
127
+ ```
128
+
129
+ Ruby uses `reference.observe(authorization_context:) { |event| ... }` and
130
+ `reference.diagnostics(authorization_context:, limit: 50)`. Stop observing by
131
+ calling the returned proc. `on` filters one event name, such as `message.retry`.
132
+ Observers receive only this actor's events in the current runtime/process; they
133
+ are not subscriptions to workers on other hosts. Dispose them when the caller's
134
+ session ends or authorization is revoked. Each runtime or process accepts at most
135
+ 1,000 local observers. More observers raise `RangeError` in JavaScript and
136
+ `ArgumentError` in Ruby. An observer needs a callback or a block. JavaScript
137
+ rejects a missing `onEvent` with `TypeError`, and Ruby raises `ArgumentError`
138
+ without a block. Both checks run before authorization.
139
+
140
+ The JavaScript Durable Objects host does not support process-local reference
141
+ observers. On that host, `observe` and `on` raise `UnsupportedCapability`, and
142
+ remote `reference.diagnostics` works. To observe a remote actor, configure
143
+ `instrumentation` on the actor host and filter the events by actor identity.
144
+ Ruby has no Durable Objects host.
145
+
146
+ Both APIs default to denied. Set `authorizeAdministration` / `authorize_administration`
147
+ to allow action `observe` or `inspect`, resource `actor_diagnostics`, and resource ID
148
+ `JSON.stringify([actorType, actorId])`. Ruby receives a symbol action. Authorization
149
+ runs before reading summaries or registering observers; possessing an actor ID
150
+ confers no permission.
151
+
152
+ Diagnostics read at most `limit + 1` rows per queue source, with a hard limit
153
+ of 100 rows. Each category returns `sampled`, `truncated`, and
154
+ `oldestAgeMilliseconds`.
155
+ The limit applies to each combined category: one effect plus one broadcast with
156
+ `limit: 1` returns `sampled: 1, truncated: true`, even when both source queries
157
+ returned all their rows. The extra row proves that the category exceeds its cap.
158
+ The last value measures nonnegative time since availability (or terminal failure
159
+ for recovery callbacks); future reminders have zero age. No payloads or row
160
+ identifiers are returned. Samples are observations across several queries,
161
+ not an atomic fleet snapshot. Large queues can still require database scanning;
162
+ the bound limits materialized rows and response size, not query execution time.
163
+
164
+ Categories are mailbox (ready and claimed), outbox (pending and processing effects
165
+ and broadcasts), reminders (scheduled and paused), retries (failed messages still
166
+ eligible to run), and recoveryFailures (dead internal effect recovery
167
+ callback messages). Durable Objects does not implement process-heartbeat effect recovery;
168
+ its recoveryFailures category is empty. Recovery failures are durable records,
169
+ not a history of transient database or exporter exceptions.
170
+
171
+ ## A query that works across adapters
172
+
173
+ Write one envelope per line to `events.jsonl`, using the same instrumentation hook
174
+ with SQLite and PostgreSQL. This query reports completed attempts by adapter:
175
+
176
+ ```sh
177
+ jq -s 'map(select(.name == "solid_objects.message.completed"))
178
+ | group_by(.adapter)
179
+ | map({adapter: .[0].adapter, completed: length,
180
+ mean_ms: (map(.attributes.durationMilliseconds) | add / length)})' events.jsonl
181
+ ```
182
+
183
+ A dashboard can chart the event counter by adapter and outcome and the duration,
184
+ reminder lateness, and outbox age distributions using exactly the same fields.
185
+ Compare workloads with the same actor types and sampling policy. Counts measure
186
+ observations and cannot replace a database query for authoritative queue state.
data/docs/operations.md CHANGED
@@ -26,22 +26,24 @@ reports a failed or warned check rather than raising out of the command.
26
26
  ## Installing and upgrading
27
27
 
28
28
  Solid Objects keeps actor state, message arguments, results, and the remembered
29
- idempotency keys in JSON columns. Active Support decodes every one of them, and
30
- `ActiveSupport::JSON.decode` raises with the `json` gem at 3.0.2:
29
+ idempotency keys in JSON columns. Active Support encodes and decodes every one
30
+ of them. The `json` gem 3.x works only with Active Support 8.1.4 or newer:
31
31
 
32
- ```
33
- ArgumentError: wrong number of arguments (given 2, expected 1)
34
- ```
32
+ - Active Support 7.1, 7.2, and 8.0 raise
33
+ `ArgumentError: unknown keyword: quirks_mode` when they encode or decode.
34
+ - Active Support 8.1.3.1 and earlier 8.1 releases raise
35
+ `ArgumentError: wrong number of arguments (given 2, expected 1)` when they
36
+ decode.
35
37
 
36
38
  The failure is in Active Support rather than in Solid Objects, and it reaches
37
- every JSON column in a Rails application. A new Rails 8.1 application resolves
38
- `json` 3.0.2 today, so pin the 2.x series until Rails ships a fix:
39
+ every JSON column in a Rails application. Upgrade Rails to 8.1.4 or newer. On
40
+ an older Rails release, pin the 2.x series of `json`:
39
41
 
40
42
  ```ruby
41
43
  gem "json", "~> 2"
42
44
  ```
43
45
 
44
- Review [CHANGELOG.md](CHANGELOG.md) for compatibility and deployment-order
46
+ Review [CHANGELOG.md](../CHANGELOG.md) for compatibility and deployment-order
45
47
  notes, then update the gem:
46
48
 
47
49
  ```bash
data/docs/realtime.md CHANGED
@@ -347,8 +347,9 @@ object or array so the wire format stays inspectable.
347
347
  A payload is one subscriber's view of one name, so a failure is confined to it.
348
348
  A raising block does not reject the subscription, stop the other payload names,
349
349
  or stop component refreshes on the same connection. The failure is reported as
350
- `solid_objects.payload_broadcast_failed` carrying the actor type, actor id,
351
- payload name, and exception class. The exception message is deliberately not
350
+ `solid_objects.payload_broadcast.failed` carrying the actor type, actor id,
351
+ payload name, and exception class. The Active Support payload names the payload
352
+ `payload_name`; the portable instrumentation event names it `payload`. The exception message is deliberately not
352
353
  included: a payload block reads subscriber state, so its message is the one
353
354
  place that state could leak into logs.
354
355
 
@@ -356,7 +357,7 @@ A revision with a failed payload does not advance the delivery watermark, so a
356
357
  transient failure is retried on the next broadcast rather than being recorded as
357
358
  delivered. Retries are driven by broadcasts rather than a timer, so a payload
358
359
  that fails persistently retries once per actor mutation and reports each
359
- attempt. A repeating stream of `payload_broadcast_failed` for one `payload_name`
360
+ attempt. A repeating stream of `payload_broadcast.failed` for one `payload_name`
360
361
  therefore means a persistent fault in that block, not a one-off; a single event
361
362
  that does not recur was transient and has already been recovered.
362
363
 
data/docs/reminders.md CHANGED
@@ -65,7 +65,9 @@ key only decides which alarm is which.
65
65
 
66
66
  A key must be non-empty, and the name it becomes must fit the 191-character
67
67
  column, which is checked on the composed name rather than the key alone so a
68
- long operation and a short key are caught too.
68
+ long operation and a short key are caught too. JavaScript allows 255 UTF-16 code
69
+ units for the same combined name. These existing schema limits remain different;
70
+ use at most 191 ASCII characters for names shared across runtimes.
69
71
 
70
72
  The key is separated from the operation by a colon, so an operation may not hold
71
73
  one. Otherwise an unkeyed `deliver:item` and a `deliver` keyed `item` would be
data/docs/roadmap.md CHANGED
@@ -2,8 +2,19 @@
2
2
 
3
3
  ## Implemented and tested
4
4
 
5
+ - Portable telemetry with a shared JSON schema, metric samples, isolated observer
6
+ callbacks, bounded authorized actor diagnostics, and matching timeout wait
7
+ reasons and activation metadata. Both test suites check the attribute keys of
8
+ each core SQL event against `compatibility/telemetry-events.json`. See
9
+ [observability](observability.md).
10
+
5
11
  - Rails engine, install generator, migration, and CLI
6
12
  - Explicit actor registry, references, JSON state, and state migrations
13
+ - Queries, observable projections, and personalized payloads reject state mutation
14
+ and staged durable work with `QueryMutatedState`, matching JavaScript. Query
15
+ and observable violations fail terminally; a payload violation is confined to
16
+ that payload. Shared JSON fixtures preserve reserved property names as ordinary
17
+ data in both runtimes.
7
18
  - Fluent direct synchronous RPC, configured `sync`, and durable `async`
8
19
  - Durable message history plus ready/claimed membership tables
9
20
  - Concurrent sequence allocation and actor creation. An enqueue finds the
@@ -55,7 +66,9 @@
55
66
  subscriber's authorization context, fenced by actor revision, resolved through
56
67
  `payload_authorization_context` so the block and `authorize_query` see the
57
68
  same subject a controller render passes, and confined so one failing payload
58
- cannot reject the subscription or stop its siblings
69
+ cannot reject the subscription or stop its siblings. Each payload uses an
70
+ isolated actor from the same committed snapshot, prevents application database
71
+ writes, and enforces the configured `max_payload_bytes` limit
59
72
  - Reconciliation read APIs
60
73
  - Installation doctor, authorization reference, fit guide, and legacy-state
61
74
  migration cookbook. The doctor names every column that a migration after the
@@ -119,6 +132,9 @@
119
132
  batched and unbatched components, an inert replay of an applied revision,
120
133
  cancellation of the request left in flight by the drop, incarnation ordering
121
134
  after a destroy and recreate, and payload delivery exactly once per revision
135
+ - Authorized message-reference reads recheck the original operation and arguments,
136
+ return immutable JSON results for every delivery mode, and raise terminal errors
137
+ from `result` while `outcome` exposes them as data.
122
138
  - Result lookup by request ID and by idempotency key, authorized with the hook
123
139
  the original call ran and against the stored operation and arguments. An
124
140
  actor remembers the idempotency keys of its own last `retained_idempotency_keys`
data/docs/security.md CHANGED
@@ -16,8 +16,8 @@ delegate to the authorized synchronous invocation path. Keep implementation
16
16
  helpers private or protected. Query, attribute, observable, and committed
17
17
  `snapshot` reads use the separate query authorization policy. Explicit `async`
18
18
  message delivery uses the same message authorization policy as direct calls.
19
- Recovering a timed-out result through `MessageReference#wait` reauthorizes the
20
- stored operation.
19
+ Message reference status, result, outcome, and wait reads reauthorize the stored
20
+ operation and arguments with the context supplied to that read.
21
21
  `reference.destroy` delegates to `authorize_destroy` before checking whether
22
22
  the actor exists, so denial does not reveal actor existence.
23
23
 
data/docs/transmission.md CHANGED
@@ -18,6 +18,9 @@ actor does the same with `transmit.increment(amount:)`. Either ingest
18
18
  accepts either sender, so Rails-to-Rails, Rails-to-Node, Node-to-Rails,
19
19
  and browser-to-Rails replication all ride one contract.
20
20
 
21
+ An omitted `arguments` field defaults to `{}`. Explicit `null` and arrays are
22
+ rejected by both runtimes. Shared fixtures cover this distinction.
23
+
21
24
  ## The sending side
22
25
 
23
26
  ```ruby
@@ -57,7 +60,7 @@ retries with backoff and dead-letters on exhaustion, like any other effect.
57
60
 
58
61
  The drain keeps per-actor order across failures: a claimed transmit effect
59
62
  delivers every undelivered sibling for its actor up to its own mailbox
60
- sequence, oldest first. The receiving side dedups on `transmit:<effectId>`,
63
+ sequence, oldest first, preserving staging order within each turn. The receiving side dedups on `transmit:<effectId>`,
61
64
  so a redelivered envelope applies once.
62
65
 
63
66
  Delivery is at-least-once by design, and the drain accepts redundant sends
@@ -84,11 +87,19 @@ SolidObjects.configure do |configuration|
84
87
  end
85
88
  ```
86
89
 
87
- These settings apply to every effect, not only transmits. A dead transmit
88
- effect has no retry API; the dashboard lists it, and recovery means
89
- returning its row to `pending` with a cleared `attempt_count`. Order
90
- survives that recovery, because the drain orders by mailbox sequence, not
91
- by retry time.
90
+ These settings apply to every effect, including transmits. Retry a dead transmit
91
+ through the authorized administration API:
92
+
93
+ ```ruby
94
+ SolidObjects.dead_letters.effects.retry(effect_id, authorization_context: operator)
95
+ ```
96
+
97
+ Retry resets attempts and returns the effect to pending with its stable identity,
98
+ so the receiver still deduplicates replays. The administration policy must allow
99
+ `retry` on `effect_dead_letters`; the action is recorded in the audit log. Use
100
+ `SolidObjects.dead_letters.effects.redrive(authorization_context: operator)` to
101
+ recover a scope in bounded batches. Order survives recovery because the drain
102
+ orders by source sequence and staging order.
92
103
 
93
104
  ## Wire contract
94
105
 
@@ -0,0 +1,226 @@
1
+ # Virtual actors in Ruby on Rails
2
+
3
+ ## Short answer
4
+
5
+ Yes. Solid Objects is a SQL-backed virtual actor library for Ruby on Rails.
6
+ The gem is `solid_objects`. It gives each actor a stable identity, durable
7
+ state, ordered operations, and automatic activation.
8
+
9
+ Solid Objects requires Rails. It is a Rails engine for Ruby 3.3 or newer and
10
+ Rails 7.1 or newer. It is not a framework-independent Ruby actor runtime.
11
+ State and mailboxes live in the SQLite, PostgreSQL, or MySQL database that the
12
+ Rails application already uses. Redis and a separate actor service are not
13
+ necessary.
14
+
15
+ Solid Objects is a pre-1.0 release. It makes no production-ready claim. Read
16
+ [Compatibility and maturity](#compatibility-and-maturity) before you choose it.
17
+
18
+ ## What a virtual actor is
19
+
20
+ A virtual actor is a logical object that always exists by name. The caller
21
+ does not create it, start it, or stop it. The runtime loads it when a message
22
+ arrives and releases it when it is idle. Microsoft Orleans made this model
23
+ known as "virtual actors".
24
+
25
+ Solid Objects implements four properties of that model:
26
+
27
+ | Property | What it means | How Solid Objects does it |
28
+ | --- | --- | --- |
29
+ | Stable identity | An actor is addressed by type and ID, for example one cart per user. | `ShoppingCart.ref(user.id)` returns a cheap reference. The reference does not load the actor. |
30
+ | Automatic activation | The first message activates the actor. An idle actor is released. | A process claims a fenced activation lease when work arrives. The lease is released after the idle timeout. |
31
+ | Durable state | State survives process restarts and deploys. | Actor attributes are a JSON document in the application database. |
32
+ | Ordered turns | One identity runs one operation at a time, in a fixed order. | Each call is a durable mailbox message with a per-actor sequence number. |
33
+
34
+ Different identities run concurrently. One identity is a serialization point
35
+ on purpose.
36
+
37
+ ## A small example
38
+
39
+ This actor holds one ticket for a buyer and releases the hold after ten
40
+ minutes. Put it in `app/actors/ticket_sale.rb`:
41
+
42
+ ```ruby
43
+ class TicketSale < SolidObjects::Actor
44
+ attribute :available, default: 1
45
+ attribute :holds, default: -> { {} }
46
+
47
+ def hold(buyer:)
48
+ return { held: false, available: } if available.zero? || holds.key?(buyer)
49
+
50
+ self.available -= 1
51
+ self.holds = holds.merge(buyer => Time.current.to_i)
52
+ schedule(at: 10.minutes.from_now, key: buyer).expire(buyer:)
53
+ { held: true, available: }
54
+ end
55
+
56
+ def expire(buyer:)
57
+ return available unless holds.key?(buyer)
58
+
59
+ self.holds = holds.except(buyer)
60
+ self.available += 1
61
+ end
62
+ end
63
+ ```
64
+
65
+ Call it from a controller, a job, or the console:
66
+
67
+ ```ruby
68
+ TicketSale.ref("event-42").hold(buyer: "ada")
69
+ ```
70
+
71
+ Two concurrent `hold` calls for `event-42` enter the same mailbox. They commit
72
+ one at a time, so only one buyer gets the ticket. The hold and its reminder
73
+ commit in one transaction. The reminder runs in the Solid Objects runtime
74
+ process:
75
+
76
+ ```bash
77
+ bundle exec solid_objects start
78
+ ```
79
+
80
+ If that process stops, the reminder stays in SQL. It runs when the process
81
+ starts again.
82
+
83
+ The example needs installation, migrations, and an authorization policy. The
84
+ generated policies deny every operation by default. For the complete setup,
85
+ use one of these guides:
86
+
87
+ - [Installation](../README.md#installation) in the README.
88
+ - [The clean-install quickstart](../examples/quickstart/README.md), which a
89
+ smoke check runs against the built gem in a new Rails application.
90
+ - [The agent guide](agents.md), which gives setup and verification steps for
91
+ coding agents.
92
+
93
+ ## When to use it
94
+
95
+ Solid Objects is a good candidate when most of these conditions are true:
96
+
97
+ - State belongs to one durable identity, such as a cart, room, booking,
98
+ device, account, or workflow.
99
+ - Writes for that identity must be serialized across requests, jobs, and
100
+ processes.
101
+ - The work must happen later, survive a restart, or stay ordered across more
102
+ than one request.
103
+ - The state is a bounded JSON document.
104
+ - The identity needs reminders, external effects, or reactive Rails views that
105
+ follow its committed state.
106
+
107
+ ## When to use something else
108
+
109
+ Do not use an actor when a simpler tool enforces the invariant:
110
+
111
+ - One short transaction, `with_lock`, or a database constraint is enough. A row
112
+ lock is often the clearest answer.
113
+ - The work is CPU-intensive or data-parallel. An actor serializes work. It does
114
+ not add CPU parallelism.
115
+ - One hot identity must accept many writes for each second, for example a
116
+ request-path rate limiter or a page-view counter.
117
+ - The state is large, relational, or query-heavy. Keep that data in normal
118
+ tables.
119
+ - The operation must change two actor identities in one atomic transaction.
120
+ Solid Objects has no cross-actor transactions.
121
+ - You need exactly-once calls to an external API. No actor library can promise
122
+ that through every network failure. Solid Objects gives at-least-once
123
+ delivery and stable effect IDs for idempotency keys.
124
+ - You need replay of named workflow steps from a step log. That is a durable
125
+ execution engine, not an actor.
126
+
127
+ [Choosing Solid Objects](fit.md) has the full checklist and the cost model.
128
+
129
+ ## How it compares
130
+
131
+ This table compares coordination models for a Rails application. It does not
132
+ rank the projects. Facts about other projects were checked on October 7, 2026,
133
+ against the sources in [Primary references](#primary-references).
134
+
135
+ | Approach | Unit of order | Durable state | Delayed work | Extra service | Good for |
136
+ | --- | --- | --- | --- | --- | --- |
137
+ | `with_lock` or a short SQL transaction | Rows in one transaction | Application tables | None | No | An invariant that fits in one request |
138
+ | Active Job with Solid Queue | None. `limits_concurrency` limits overlap for each key, but it does not set an order | Application tables, owned by the application | Scheduled jobs | No; Solid Queue uses the database | Background work that does not own entity state |
139
+ | Sidekiq | None in the open-source gem. Unique jobs and rate limits are Sidekiq Enterprise features | Application tables, owned by the application | Scheduled jobs | Redis | High-volume background jobs |
140
+ | In-process concurrency: Ractor, `concurrent-ruby-edge` actors, Async | One Ruby object in one process | None. State is lost when the process stops | In process only | No | Parallel or concurrent work inside one process. Ractor is experimental in Ruby 4.0 |
141
+ | Solid Objects | Actor type and ID | JSON state in the application database | Durable per-actor reminders | No; the `solid_objects start` process runs in the app | Durable per-identity state with ordered operations |
142
+ | Dapr actors | Actor type and ID, one turn at a time | A transactional Dapr state store | Durable reminders through the Dapr Scheduler service | A Dapr sidecar, plus the placement and Scheduler services. Dapr has no official Ruby SDK | Polyglot services on a Dapr platform |
143
+ | Temporal (`temporalio` gem) | A workflow execution | Temporal event history | Durable timers | A Temporal Service, self-hosted or Temporal Cloud | Long-running workflows that replay deterministic code |
144
+
145
+ ### Orleans concept map
146
+
147
+ Orleans is the reference design for virtual actors on .NET. Solid Objects
148
+ uses the same programming model on a SQL database. It does not copy the
149
+ Orleans cluster, placement, or feature set.
150
+
151
+ | Orleans | Solid Objects | Difference |
152
+ | --- | --- | --- |
153
+ | Grain class | `SolidObjects::Actor` subclass | None in concept |
154
+ | Grain identity (key) | Actor type and actor ID | None in concept |
155
+ | Activation on first call | Activation lease on first claimed message | Solid Objects fences each activation with a database generation |
156
+ | Turn-based execution | Ordered mailbox, one turn at a time | Orleans can enable reentrancy. Solid Objects turns for one identity never interleave |
157
+ | Grain persistence | JSON attributes in the application database | An Orleans grain calls `WriteStateAsync`. Solid Objects persists state with the turn that changed it |
158
+ | Reminders | `schedule` | Both are durable. Orleans skips a tick that falls due while the cluster is down. A due Solid Objects reminder runs when a runtime process starts. Solid Objects has no non-durable timers |
159
+ | Silos and cluster membership | Any Rails process that runs `solid_objects start` | Solid Objects has no placement, directory, or cluster membership. The database is the coordination point |
160
+ | Streams | Observables, broadcasts, and reactive ERB | Solid Objects delivers committed revisions to Action Cable |
161
+
162
+ Solid Objects delivery is at least once. Write each handler so that it can run
163
+ again without harm.
164
+
165
+ ## Guarantees and boundaries
166
+
167
+ - Calls are durably ordered per identity. Different identities can run
168
+ concurrently.
169
+ - Delivery is at least once, not exactly once. A handler can start again after
170
+ a crash or a lost lease.
171
+ - Ordered turns do not cancel stale Ruby code. Fencing stops a stale activation
172
+ from a commit, but that code can continue to run.
173
+ - External effects can run more than once. Use the stable effect ID, or
174
+ another durable key, as the idempotency key at the provider.
175
+ - There are no transactions across actor identities, and there is no replay of
176
+ durable function steps.
177
+ - Reminders, `async` calls, effects, and broadcasts need the
178
+ `bundle exec solid_objects start` process. When that process stops, the work
179
+ waits in SQL. The gem does not supply a hosted worker.
180
+ - One hot identity is sequential. The core gem is not a high-throughput
181
+ request-path rate limiter.
182
+ - The guarantees apply only to changes made through the actor APIs. Actor
183
+ fencing does not protect direct writes to the same data or other external
184
+ requests.
185
+
186
+ The [correctness contract](correctness.md) states each guarantee and the crash
187
+ matrix.
188
+
189
+ ## Compatibility and maturity
190
+
191
+ - Ruby 3.3 or newer and Rails 7.1 or newer. CI runs Ruby 3.3, 3.4, and 4.0
192
+ against Rails 7.1, 7.2, 8.0, and 8.1.
193
+ - SQLite 3.35 or newer, PostgreSQL 14 or newer, or MySQL 8.0 or newer with
194
+ InnoDB. MySQL works through `mysql2` or `trilogy`.
195
+ - SQLite is correct for the same contract, but it is best for development and
196
+ modest single-host workloads.
197
+ - Pre-1.0. Expect changes that break compatibility. The correctness core has tests against all
198
+ three databases. The project has no production-ready claim and no measured
199
+ scale claim.
200
+
201
+ The [roadmap](roadmap.md) records what is tested, what is partial, and what is
202
+ next. For TypeScript and Node.js, use the
203
+ [solid-objects](https://github.com/cardmagic/solid-objects-js) package.
204
+
205
+ ## Primary references
206
+
207
+ - Orleans: [Overview](https://learn.microsoft.com/en-us/dotnet/orleans/overview),
208
+ [Request scheduling](https://learn.microsoft.com/en-us/dotnet/orleans/grains/request-scheduling),
209
+ [Timers and reminders](https://learn.microsoft.com/en-us/dotnet/orleans/grains/timers-and-reminders),
210
+ [Grain persistence](https://learn.microsoft.com/en-us/dotnet/orleans/grains/grain-persistence/),
211
+ and [Grain placement](https://learn.microsoft.com/en-us/dotnet/orleans/grains/grain-placement).
212
+ - Ruby: [Ractor](https://docs.ruby-lang.org/en/4.0/Ractor.html) and the
213
+ [Ruby 4.0.0 release notes](https://www.ruby-lang.org/en/news/2025/12/25/ruby-4-0-0-released/).
214
+ - concurrent-ruby: [repository](https://github.com/ruby-concurrency/concurrent-ruby)
215
+ and [`Concurrent::Actor`](https://ruby-concurrency.github.io/concurrent-ruby/master/Concurrent/Actor.html).
216
+ - Solid Queue: [concurrency controls](https://github.com/rails/solid_queue#concurrency-controls).
217
+ - Sidekiq: [Enterprise unique jobs](https://github.com/sidekiq/sidekiq/wiki/Ent-Unique-Jobs)
218
+ and [Enterprise rate limiting](https://github.com/sidekiq/sidekiq/wiki/Ent-Rate-Limiting).
219
+ - Dapr: [Actors overview](https://docs.dapr.io/developing-applications/building-blocks/actors/actors-overview/),
220
+ [Actor timers and reminders](https://docs.dapr.io/developing-applications/building-blocks/actors/actors-timers-reminders/),
221
+ and [SDKs](https://docs.dapr.io/developing-applications/sdks/).
222
+ - Temporal: [Ruby SDK](https://github.com/temporalio/sdk-ruby) and
223
+ [Event History](https://docs.temporal.io/encyclopedia/event-history).
224
+
225
+ Other projects change. Check these sources again before you base an
226
+ architecture decision on one row.