solid_objects 0.16.1 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +67 -0
- data/README.md +1 -0
- data/app/models/solid_objects/message.rb +15 -0
- data/docs/architecture.md +16 -6
- data/docs/authorization.md +10 -3
- data/docs/observability.md +186 -0
- 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/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 +10 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 27c3d17cf47d0d22b069ed5fbddfb948495fd1c62ef807e43d0f271511a63ea8
|
|
4
|
+
data.tar.gz: 1161d2b5f269a22f9b509e73a0cacc8f46c0a0528bb7e54be9c2fdf81ad0055b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f9ae16fca8e08fd41806a22a9a7f27904d0492870e52a9f1b4b9d9ca3bb6f95b85d965fd9b40e82ddc3154334564067ba36d090a8379e71c111c5dc043a0ce66
|
|
7
|
+
data.tar.gz: 93e89b4878dac64ed92ebc50361720bf0c43a713ef069be0f5f4988bad29045cc6ca8c1aeb857510fe7830b6e6df0a3f4a5a2cbc503dedea56a4177b15b7af36
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,72 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.17.0 - 2026-10-03
|
|
4
|
+
|
|
5
|
+
- Publish RBS types for portable events, metric samples, actor diagnostics, and
|
|
6
|
+
event observers in `sig/public/telemetry.rbs`, and a `json_value` type for
|
|
7
|
+
message results and actor state. Observer blocks, diagnostics, and results now
|
|
8
|
+
type-check against these contracts instead of `untyped`.
|
|
9
|
+
- **Breaking:** `solid_objects.activation.started` now fires before the actor's
|
|
10
|
+
`activate` hook. Before, it fired after a successful hook. The new
|
|
11
|
+
`solid_objects.activation.completed` event takes that meaning, and
|
|
12
|
+
`solid_objects.activation.failed` reports a failed hook. JavaScript changes
|
|
13
|
+
the same events. Move a subscriber that reads `activation.started` as a
|
|
14
|
+
finished activation to `activation.completed`.
|
|
15
|
+
- **Breaking:** Active Support payloads no longer carry `error_message`. This
|
|
16
|
+
applies to `commit_action.failed`, `activation.deactivation_failed`,
|
|
17
|
+
`supervisor.monitor_failed`, `supervisor.retention_failed`,
|
|
18
|
+
`supervisor.redrive_failed`, and `wake_up.failed`. The
|
|
19
|
+
`solid_objects.worker.error` log entry also omits it. Each keeps
|
|
20
|
+
`error_class`. Exception text can contain actor state, so JavaScript already
|
|
21
|
+
reports only the error name.
|
|
22
|
+
|
|
23
|
+
- **Breaking:** rename `solid_objects.payload_broadcast_failed` to
|
|
24
|
+
`solid_objects.payload_broadcast.failed`, the dotted form that every other
|
|
25
|
+
event uses. Update Active Support subscribers to the new name. The portable
|
|
26
|
+
event names the payload `payload`.
|
|
27
|
+
- Match portable event attributes to JavaScript through the shared
|
|
28
|
+
`compatibility/telemetry-events.json` contract. Message events carry
|
|
29
|
+
`operation` and `deliveryMode`, `message.failed` carries `retryable` and
|
|
30
|
+
`outcome`, commit action events carry the message fields and `commitAction`,
|
|
31
|
+
and `reminder.enqueued` carries `operation`. `outbox.age` carries the effect or
|
|
32
|
+
broadcast identity, `sync.enqueue_timeout` carries `timeoutMilliseconds`, and
|
|
33
|
+
polling intervals are integers. `realtime.connected` carries only actor fields.
|
|
34
|
+
- Log `solid_objects.instrumentation.failed` when an exporter or observer raises.
|
|
35
|
+
Observers require a block, a process accepts at most 1,000 observers, and
|
|
36
|
+
`SolidObjects.reset!` removes them. Pin reserved JSON keys through actor
|
|
37
|
+
arguments, state, and retained results.
|
|
38
|
+
|
|
39
|
+
- Guard personalized payload projections against state changes, staged work,
|
|
40
|
+
and application database writes. Each payload gets an isolated actor from
|
|
41
|
+
the committed snapshot and honors `max_payload_bytes`, matching JavaScript.
|
|
42
|
+
- Preserve timeout wait reasons, activation owner IDs, and activation generations
|
|
43
|
+
in portable telemetry using the shared camelCase fields and reason values.
|
|
44
|
+
- Use a yielding SQLite busy handler for background transactions so concurrent
|
|
45
|
+
writers can finish on Rails 7.1 and 7.2. Preserve configured wait limits and
|
|
46
|
+
synchronous deadlines; cover contention with a coordinated lock regression.
|
|
47
|
+
|
|
48
|
+
- **Breaking:** reject query and observable state mutation and staged durable
|
|
49
|
+
work with terminal `QueryMutatedState` errors. Cover individual snapshot
|
|
50
|
+
projections and preserve ordinary operations' already-staged work while
|
|
51
|
+
reading projections, including replacements that leave the intent count
|
|
52
|
+
unchanged.
|
|
53
|
+
- Pin reserved JSON property names with shared Ruby/JS fixtures. Document the
|
|
54
|
+
reminder-name limit difference and the authorized dead-transmit retry API.
|
|
55
|
+
|
|
56
|
+
- **Breaking:** reauthorize every message-reference status, result, and outcome
|
|
57
|
+
read against the original invocation. Pass `authorization_context:` on every
|
|
58
|
+
read.
|
|
59
|
+
- **Breaking:** retain immutable JSON results for background and internal
|
|
60
|
+
messages as well as synchronous calls. All operations now enforce result
|
|
61
|
+
serialization and size limits; return `nil` explicitly when an operation does
|
|
62
|
+
not need a result. `result` raises terminal rejection/failure errors;
|
|
63
|
+
`outcome` exposes them as data.
|
|
64
|
+
- Preserve polling transition intervals in milliseconds and string reasons in
|
|
65
|
+
portable telemetry. Pin transmit staging order and null-argument validation
|
|
66
|
+
against the shared JavaScript contract.
|
|
67
|
+
|
|
68
|
+
- Add portable telemetry, isolated observer hooks, metric definitions, and bounded authorized actor diagnostics matching JavaScript.
|
|
69
|
+
|
|
3
70
|
## 0.16.1 - 2026-10-02
|
|
4
71
|
|
|
5
72
|
- Fix the SQLite join order of the claimed-message scan. The query in
|
data/README.md
CHANGED
|
@@ -162,6 +162,7 @@ Exactly once is not hiding in a more advanced configuration. Read the
|
|
|
162
162
|
- [Five-minute Rails guide](https://solidobjects.dev/5min/rails)
|
|
163
163
|
- [Choosing Solid Objects](docs/fit.md)
|
|
164
164
|
- [Operations and recovery](docs/operations.md)
|
|
165
|
+
- [Observability and diagnostics](docs/observability.md)
|
|
165
166
|
- [Reminders](docs/reminders.md)
|
|
166
167
|
- [Reactive ERB](docs/realtime.md)
|
|
167
168
|
- [Detailed architecture](docs/architecture.md)
|
|
@@ -47,6 +47,21 @@ module SolidObjects
|
|
|
47
47
|
dead_letter.present?
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
+
# @rbs () -> json_value
|
|
51
|
+
def result!
|
|
52
|
+
if rejected?
|
|
53
|
+
raise Rejected.new(
|
|
54
|
+
code: rejection.fetch("code"),
|
|
55
|
+
message: rejection.fetch("message"),
|
|
56
|
+
details: rejection.fetch("details"),
|
|
57
|
+
message_id: id
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
raise MessageFailed.new("actor message failed permanently", message_id: id, details: error || {}) if dead?
|
|
61
|
+
|
|
62
|
+
Serialization.readonly_copy(result)
|
|
63
|
+
end
|
|
64
|
+
|
|
50
65
|
private
|
|
51
66
|
|
|
52
67
|
# @rbs () -> void
|
data/docs/architecture.md
CHANGED
|
@@ -167,10 +167,12 @@ creating a message or activation, applies required state migrations in memory,
|
|
|
167
167
|
and returns deeply frozen declared attributes. It can race with an in-flight
|
|
168
168
|
turn. `SolidObjects.mutable_copy` creates an independent mutable JSON value.
|
|
169
169
|
|
|
170
|
-
`message` and `query` both execute as durable mailbox turns.
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
170
|
+
`message` and `query` both execute as durable mailbox turns. Queries and
|
|
171
|
+
observables must not mutate state or stage effects, recovery checks, commit
|
|
172
|
+
actions, reminders, or outbound messages. Violations raise `QueryMutatedState`
|
|
173
|
+
and fail the message without retrying or committing its work. Individual snapshot
|
|
174
|
+
projections enforce the same rule. An observable is a named projection used by
|
|
175
|
+
server rendering and realtime updates. Its durable broadcast row stores only an empty invalidation marker by
|
|
174
176
|
default. `broadcast: :value` explicitly opts into storing and sharing the
|
|
175
177
|
projected value.
|
|
176
178
|
|
|
@@ -431,8 +433,16 @@ Each lookup runs the authorization hook the original call ran, against the
|
|
|
431
433
|
stored operation and arguments, and answers `nil` for an absent row, an
|
|
432
434
|
unregistered actor, and a refused caller alike, so it cannot be used to ask
|
|
433
435
|
whether a request id exists. `MessageReference#outcome` reports the status, the
|
|
434
|
-
result, the persisted error, the rejection, and the attempt count.
|
|
435
|
-
|
|
436
|
+
result, the persisted error, the rejection, and the attempt count. Every delivery
|
|
437
|
+
mode stores its JSON result, including background messages. Result serialization
|
|
438
|
+
and `max_result_bytes` apply before commit; a result that cannot be stored fails
|
|
439
|
+
the turn. Return `nil` explicitly from operations that need no result.
|
|
440
|
+
|
|
441
|
+
`status`, `result`, and `outcome` reauthorize the stored operation on every read.
|
|
442
|
+
Pass `authorization_context:` each time; references retain identity rather than
|
|
443
|
+
caller permissions. `result` raises `Rejected` or `MessageFailed` for terminal
|
|
444
|
+
errors and returns a deeply frozen successful value. `outcome` reports terminal
|
|
445
|
+
errors as data.
|
|
436
446
|
|
|
437
447
|
An actor remembers the idempotency keys of its own finished turns. The executor
|
|
438
448
|
already writes the instance row in the transaction that completes, rejects, or
|
data/docs/authorization.md
CHANGED
|
@@ -13,13 +13,20 @@ answers nothing until the host application defines its trust boundary.
|
|
|
13
13
|
| `authorize_query` | Attribute reads, declared queries, committed snapshots, scalar observable reads, initial component rendering, and every component refresh dependency | Explicit call context, the context passed to `solid_object`, or the request context resolved for a component refresh | Actor state or personalized projections can leak across users or tenants |
|
|
14
14
|
| `authorize_destroy` | `reference.destroy` | Value passed as `authorization_context:` | Complete actor state, mailbox, reminders, and pending outboxes can be deleted |
|
|
15
15
|
| `authorize_subscription` | Action Cable subscription to one actor stream | The `ActionCable::Connection` object | Clients can receive future observable updates for other actors |
|
|
16
|
-
| `authorize_administration` | Engine administration controllers, every `SolidObjects::Web` page, process inspection/cleanup/pruning, message pruning,
|
|
16
|
+
| `authorize_administration` | Engine administration controllers, every `SolidObjects::Web` page, process inspection/cleanup/pruning, message pruning, dead-letter inspection/retry, and actor diagnostics and observers | Rails controller, a `SolidObjects::Web` request that answers `request`/`session`/`env`, or `{ source: "cli" }` | Operational metadata, arguments, errors, deletion, and retries become exposed or mutable |
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
Every `MessageReference#status`, `#result`, `#outcome`, and `#wait` call
|
|
19
|
+
reauthorizes the stored operation and arguments as a message or query. Supply
|
|
20
|
+
`authorization_context:` on each call, including after `find_by`; the reference
|
|
21
|
+
does not retain the original caller's context. Internal reminder, effect-callback, and
|
|
20
22
|
actor-to-actor deliveries come from
|
|
21
23
|
already committed runtime rows and do not re-enter the public client policy.
|
|
22
24
|
|
|
25
|
+
Actor diagnostics and actor observers call `authorize_administration` with the
|
|
26
|
+
resource `actor_diagnostics` and the resource ID `[actor_type, actor_id].to_json`.
|
|
27
|
+
`diagnostics` uses the action `:inspect`. `observe` and `on` use the action
|
|
28
|
+
`:observe`. The check runs before any queue read or observer registration.
|
|
29
|
+
|
|
23
30
|
## Realtime authorization contexts
|
|
24
31
|
|
|
25
32
|
Reactive components cross three Rails execution contexts and authorize at all
|
|
@@ -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/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
|
|
|
@@ -24,15 +24,19 @@ module SolidObjects
|
|
|
24
24
|
@actor = build_actor(instance)
|
|
25
25
|
@last_used_at = monotonic_now
|
|
26
26
|
@pass_exhausted = false
|
|
27
|
+
SolidObjects.instrument(:"activation.started", instance_id: instance.id, actor_type: instance.actor_type, actor_id: instance.actor_id, owner_id: lease.owner_id, generation: lease.generation)
|
|
27
28
|
actor.activate
|
|
28
29
|
SolidObjects.instrument(
|
|
29
|
-
:"activation.
|
|
30
|
+
:"activation.completed",
|
|
30
31
|
instance_id: instance.id,
|
|
31
32
|
actor_type: instance.actor_type,
|
|
32
33
|
actor_id: instance.actor_id,
|
|
33
34
|
owner_id: lease.owner_id,
|
|
34
35
|
generation: lease.generation
|
|
35
36
|
)
|
|
37
|
+
rescue => error
|
|
38
|
+
SolidObjects.instrument(:"activation.failed", instance_id: lease.instance_id, actor_type: instance&.actor_type, actor_id: instance&.actor_id, owner_id: lease.owner_id, generation: lease.generation, error_class: error.class.name)
|
|
39
|
+
raise
|
|
36
40
|
end
|
|
37
41
|
|
|
38
42
|
# @rbs () -> Integer
|
|
@@ -104,8 +108,7 @@ module SolidObjects
|
|
|
104
108
|
actor_id: actor.actor_id,
|
|
105
109
|
owner_id: lease.owner_id,
|
|
106
110
|
generation: lease.generation,
|
|
107
|
-
error_class: error.class.name
|
|
108
|
-
error_message: error.message
|
|
111
|
+
error_class: error.class.name
|
|
109
112
|
)
|
|
110
113
|
SolidObjects.configuration.logger.error(
|
|
111
114
|
"SolidObjects activation deactivation failed " \
|
data/lib/solid_objects/actor.rb
CHANGED
|
@@ -459,7 +459,9 @@ module SolidObjects
|
|
|
459
459
|
|
|
460
460
|
# @rbs () -> Hash[String, untyped]
|
|
461
461
|
def observable_values
|
|
462
|
-
|
|
462
|
+
return {} if self.class.definition.observables.empty?
|
|
463
|
+
|
|
464
|
+
read_projection("observables") do
|
|
463
465
|
self.class.definition.observables.each_with_object({}) do |(name, handler), values|
|
|
464
466
|
values[name.to_s] = Serialization.dump(instance_exec(&handler.block))
|
|
465
467
|
end
|
|
@@ -472,7 +474,7 @@ module SolidObjects
|
|
|
472
474
|
handler = self.class.definition.observables[observable_name]
|
|
473
475
|
raise UnknownMessage, "unknown observable #{name.inspect}" unless handler
|
|
474
476
|
|
|
475
|
-
|
|
477
|
+
read_projection("observable.#{observable_name}") do
|
|
476
478
|
Serialization.dump(instance_exec(&handler.block))
|
|
477
479
|
end
|
|
478
480
|
end
|
|
@@ -537,6 +539,24 @@ module SolidObjects
|
|
|
537
539
|
outbound_message_intents.clear
|
|
538
540
|
end
|
|
539
541
|
|
|
542
|
+
# @rbs () -> Integer
|
|
543
|
+
def intent_count
|
|
544
|
+
effect_intents.length + effect_recovery_intents.length + commit_action_intents.length +
|
|
545
|
+
reminder_intents.length + outbound_message_intents.length
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
# @rbs [Result] (String) { () -> Result } -> Result
|
|
549
|
+
def read_projection(operation)
|
|
550
|
+
state_before = state.to_h
|
|
551
|
+
intents_before = intent_snapshot
|
|
552
|
+
result = guard_application_writes(operation) { yield }
|
|
553
|
+
unless state.to_h == state_before && intent_snapshot == intents_before
|
|
554
|
+
raise QueryMutatedState, "projections must not mutate actor state or stage durable work"
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
result
|
|
558
|
+
end
|
|
559
|
+
|
|
540
560
|
private
|
|
541
561
|
|
|
542
562
|
attr_reader :effect_intents,
|
|
@@ -545,6 +565,13 @@ module SolidObjects
|
|
|
545
565
|
:reminder_intents,
|
|
546
566
|
:outbound_message_intents
|
|
547
567
|
|
|
568
|
+
# @rbs () -> Array[Array[Hash[Symbol, Object]]]
|
|
569
|
+
def intent_snapshot
|
|
570
|
+
[ effect_intents, effect_recovery_intents, commit_action_intents, reminder_intents, outbound_message_intents ].map do |intents|
|
|
571
|
+
intents.map { |intent| intent.to_h.deep_dup }
|
|
572
|
+
end
|
|
573
|
+
end
|
|
574
|
+
|
|
548
575
|
# @rbs (String) { () -> untyped } -> untyped
|
|
549
576
|
def guard_application_writes(operation, &block)
|
|
550
577
|
ApplicationWriteGuard.call(
|
|
@@ -49,6 +49,7 @@ module SolidObjects
|
|
|
49
49
|
end
|
|
50
50
|
refresh_outdated_components(snapshot)
|
|
51
51
|
transmit_state_payloads(snapshot)
|
|
52
|
+
SolidObjects.instrument(:"realtime.connected", actor_type: reference.actor_type, actor_id: reference.actor_id)
|
|
52
53
|
rescue KeyError,
|
|
53
54
|
JSON::ParserError,
|
|
54
55
|
InvalidStreamToken,
|
|
@@ -57,6 +58,13 @@ module SolidObjects
|
|
|
57
58
|
reject_and_report(reject_reason(error), actor_type:, actor_id:, error:)
|
|
58
59
|
end
|
|
59
60
|
|
|
61
|
+
# @rbs () -> void
|
|
62
|
+
def unsubscribed
|
|
63
|
+
return unless reference
|
|
64
|
+
|
|
65
|
+
SolidObjects.instrument(:"realtime.disconnected", actor_type: reference.actor_type, actor_id: reference.actor_id)
|
|
66
|
+
end
|
|
67
|
+
|
|
60
68
|
private
|
|
61
69
|
|
|
62
70
|
attr_reader :reference,
|
|
@@ -141,7 +149,7 @@ module SolidObjects
|
|
|
141
149
|
true
|
|
142
150
|
rescue => error
|
|
143
151
|
SolidObjects.instrument(
|
|
144
|
-
:
|
|
152
|
+
:"payload_broadcast.failed",
|
|
145
153
|
actor_type: reference.actor_type,
|
|
146
154
|
actor_id: reference.actor_id,
|
|
147
155
|
payload_name: name,
|