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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +100 -0
- data/README.md +9 -0
- data/Rakefile +5 -0
- data/app/models/solid_objects/message.rb +15 -0
- data/docs/agents.md +253 -0
- data/docs/architecture.md +16 -6
- data/docs/authorization.md +10 -3
- data/docs/observability.md +186 -0
- data/docs/operations.md +10 -8
- data/docs/realtime.md +4 -3
- data/docs/reminders.md +3 -1
- data/docs/roadmap.md +17 -1
- data/docs/security.md +2 -2
- data/docs/transmission.md +17 -6
- data/docs/virtual-actors.md +226 -0
- data/examples/quickstart/README.md +246 -0
- data/examples/quickstart/app/actors/ticket_sale.rb +22 -0
- data/examples/quickstart/smoke.rb +421 -0
- data/lib/solid_objects/activation.rb +6 -3
- data/lib/solid_objects/actor.rb +29 -2
- data/lib/solid_objects/actor_channel.rb +9 -1
- data/lib/solid_objects/actor_snapshot.rb +14 -8
- data/lib/solid_objects/broadcast_executor.rb +1 -0
- data/lib/solid_objects/client.rb +15 -1
- data/lib/solid_objects/configuration.rb +4 -0
- data/lib/solid_objects/database_adapters/sqlite.rb +7 -1
- data/lib/solid_objects/diagnostics.rb +83 -0
- data/lib/solid_objects/effect_executor.rb +4 -0
- data/lib/solid_objects/errors.rb +3 -0
- data/lib/solid_objects/executor.rb +34 -16
- data/lib/solid_objects/instrumentation.rb +22 -1
- data/lib/solid_objects/log_subscriber.rb +1 -1
- data/lib/solid_objects/mailbox.rb +5 -1
- data/lib/solid_objects/message_reference.rb +9 -9
- data/lib/solid_objects/observer_registry.rb +47 -0
- data/lib/solid_objects/payload_broadcast.rb +9 -8
- data/lib/solid_objects/reference.rb +19 -0
- data/lib/solid_objects/reminder_scheduler.rb +5 -0
- data/lib/solid_objects/state_snapshot.rb +1 -0
- data/lib/solid_objects/supervisor.rb +3 -6
- data/lib/solid_objects/synchronous_invocation.rb +1 -21
- data/lib/solid_objects/telemetry.rb +137 -0
- data/lib/solid_objects/version.rb +1 -1
- data/lib/solid_objects/wake_up_adapters/postgresql.rb +1 -2
- data/lib/solid_objects/wake_up_adapters/redis.rb +1 -2
- data/lib/solid_objects/worker.rb +1 -2
- data/lib/solid_objects.rb +10 -0
- data/sig/generated/lib/solid_objects/actor.rbs +9 -0
- data/sig/generated/lib/solid_objects/actor_channel.rbs +3 -0
- data/sig/generated/lib/solid_objects/actor_snapshot.rbs +7 -2
- data/sig/generated/lib/solid_objects/client.rbs +3 -0
- data/sig/generated/lib/solid_objects/configuration.rbs +7 -3
- data/sig/generated/lib/solid_objects/diagnostics.rbs +25 -0
- data/sig/generated/lib/solid_objects/errors.rbs +3 -0
- data/sig/generated/lib/solid_objects/executor.rbs +10 -2
- data/sig/generated/lib/solid_objects/message_reference.rbs +6 -6
- data/sig/generated/lib/solid_objects/observer_registry.rbs +29 -0
- data/sig/generated/lib/solid_objects/payload_broadcast.rbs +2 -4
- data/sig/generated/lib/solid_objects/reference.rbs +9 -0
- data/sig/generated/lib/solid_objects/synchronous_invocation.rbs +0 -3
- data/sig/generated/lib/solid_objects/telemetry.rbs +30 -0
- data/sig/generated/lib/solid_objects.rbs +3 -0
- data/sig/generated/models/solid_objects/message.rbs +3 -0
- data/sig/public/json_value.rbs +3 -0
- data/sig/public/telemetry.rbs +49 -0
- data/sig/support/framework.rbs +5 -0
- 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
|
|
30
|
-
|
|
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:
|
|
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.
|
|
38
|
-
|
|
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.
|
|
351
|
-
payload name, and exception class. The
|
|
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 `
|
|
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
|
-
|
|
20
|
-
|
|
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,
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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.
|