solid_objects 0.16.0 → 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.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +88 -0
  3. data/README.md +1 -0
  4. data/app/models/solid_objects/message.rb +15 -0
  5. data/docs/architecture.md +16 -6
  6. data/docs/authorization.md +10 -3
  7. data/docs/observability.md +186 -0
  8. data/docs/realtime.md +4 -3
  9. data/docs/reminders.md +3 -1
  10. data/docs/roadmap.md +17 -1
  11. data/docs/security.md +2 -2
  12. data/docs/transmission.md +17 -6
  13. data/lib/solid_objects/activation.rb +6 -3
  14. data/lib/solid_objects/activation_manager.rb +2 -1
  15. data/lib/solid_objects/actor.rb +29 -2
  16. data/lib/solid_objects/actor_channel.rb +9 -1
  17. data/lib/solid_objects/actor_snapshot.rb +14 -8
  18. data/lib/solid_objects/broadcast_executor.rb +1 -0
  19. data/lib/solid_objects/client.rb +15 -1
  20. data/lib/solid_objects/configuration.rb +4 -0
  21. data/lib/solid_objects/database_adapters/sqlite.rb +7 -1
  22. data/lib/solid_objects/diagnostics.rb +83 -0
  23. data/lib/solid_objects/effect_executor.rb +4 -0
  24. data/lib/solid_objects/effect_recovery_coordinator.rb +7 -2
  25. data/lib/solid_objects/errors.rb +3 -0
  26. data/lib/solid_objects/executor.rb +34 -16
  27. data/lib/solid_objects/instrumentation.rb +22 -1
  28. data/lib/solid_objects/log_subscriber.rb +1 -1
  29. data/lib/solid_objects/mailbox.rb +5 -1
  30. data/lib/solid_objects/message_reference.rb +9 -9
  31. data/lib/solid_objects/observer_registry.rb +47 -0
  32. data/lib/solid_objects/payload_broadcast.rb +9 -8
  33. data/lib/solid_objects/reference.rb +19 -0
  34. data/lib/solid_objects/reminder_scheduler.rb +5 -0
  35. data/lib/solid_objects/state_snapshot.rb +1 -0
  36. data/lib/solid_objects/supervisor.rb +3 -6
  37. data/lib/solid_objects/synchronous_invocation.rb +1 -21
  38. data/lib/solid_objects/telemetry.rb +137 -0
  39. data/lib/solid_objects/version.rb +1 -1
  40. data/lib/solid_objects/wake_up_adapters/postgresql.rb +1 -2
  41. data/lib/solid_objects/wake_up_adapters/redis.rb +1 -2
  42. data/lib/solid_objects/worker.rb +1 -2
  43. data/lib/solid_objects.rb +10 -0
  44. data/sig/generated/lib/solid_objects/actor.rbs +9 -0
  45. data/sig/generated/lib/solid_objects/actor_channel.rbs +3 -0
  46. data/sig/generated/lib/solid_objects/actor_snapshot.rbs +7 -2
  47. data/sig/generated/lib/solid_objects/client.rbs +3 -0
  48. data/sig/generated/lib/solid_objects/configuration.rbs +7 -3
  49. data/sig/generated/lib/solid_objects/diagnostics.rbs +25 -0
  50. data/sig/generated/lib/solid_objects/errors.rbs +3 -0
  51. data/sig/generated/lib/solid_objects/executor.rbs +10 -2
  52. data/sig/generated/lib/solid_objects/message_reference.rbs +6 -6
  53. data/sig/generated/lib/solid_objects/observer_registry.rbs +29 -0
  54. data/sig/generated/lib/solid_objects/payload_broadcast.rbs +2 -4
  55. data/sig/generated/lib/solid_objects/reference.rbs +9 -0
  56. data/sig/generated/lib/solid_objects/synchronous_invocation.rbs +0 -3
  57. data/sig/generated/lib/solid_objects/telemetry.rbs +30 -0
  58. data/sig/generated/lib/solid_objects.rbs +3 -0
  59. data/sig/generated/models/solid_objects/message.rbs +3 -0
  60. data/sig/public/json_value.rbs +3 -0
  61. data/sig/public/telemetry.rbs +49 -0
  62. data/sig/support/framework.rbs +5 -0
  63. metadata +11 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2d267d861c25f682b3e45208131b9c115b49710ae43fb499223c6d63f48519a0
4
- data.tar.gz: 1acc3bb415c57e9072f231fc5e37ed37ce8bab4a9ba5fc894a7dbe5453839d8e
3
+ metadata.gz: 27c3d17cf47d0d22b069ed5fbddfb948495fd1c62ef807e43d0f271511a63ea8
4
+ data.tar.gz: 1161d2b5f269a22f9b509e73a0cacc8f46c0a0528bb7e54be9c2fdf81ad0055b
5
5
  SHA512:
6
- metadata.gz: 1f3d71f114877579419a680b8ff46b56036060d8575c7c6732c4c9d530ed3020fbb58eb61ac3e348810d44ecf09e8553b4f07390bd1c22460b5604005578e06f
7
- data.tar.gz: 07e811b54283aa3f3638ae90b8fbc449f5e151dbcae69e01487a8231a1b8cc5556bd1cc035b67aa347f080329a64f9c0394965d468860dd989f0ba40afdc7552
6
+ metadata.gz: f9ae16fca8e08fd41806a22a9a7f27904d0492870e52a9f1b4b9d9ca3bb6f95b85d965fd9b40e82ddc3154334564067ba36d090a8379e71c111c5dc043a0ce66
7
+ data.tar.gz: 93e89b4878dac64ed92ebc50361720bf0c43a713ef069be0f5f4988bad29045cc6ca8c1aeb857510fe7830b6e6df0a3f4a5a2cbc503dedea56a4177b15b7af36
data/CHANGELOG.md CHANGED
@@ -1,5 +1,93 @@
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
+
70
+ ## 0.16.1 - 2026-10-02
71
+
72
+ - Fix the SQLite join order of the claimed-message scan. The query in
73
+ `ActivationManager#claimed_instance_ids` has no condition on a
74
+ claimed-message column. The claimed-messages table is a short queue, so it is
75
+ often empty when `ANALYZE` or `PRAGMA optimize` runs, and `sqlite_stat1` then
76
+ has no row for it. Without statistics, SQLite assumed that the table was
77
+ large, and it scanned every instance on each worker poll. The scan now uses
78
+ `CROSS JOIN`, which SQLite keeps as a fixed join order, so it starts from the
79
+ claimed messages. PostgreSQL and MySQL treat `CROSS JOIN` with an equality as
80
+ an inner join and keep their plans. The ids and their order do not change.
81
+ - Find SQLite effect recovery candidates through the processing effects.
82
+ `sqlite_stat1` records only the average row count for each effect status.
83
+ When most effects are complete, SQLite estimated that `status = 'processing'`
84
+ matched most of the effects table. It then read every recovery row in key
85
+ order to skip a sort, on each effect poll. An index on the recovery filter
86
+ does not help, because a recovery row keeps `retired_at` empty after a normal
87
+ completion. On SQLite the status test now carries
88
+ `likelihood(..., 0.000001)`, so the plan starts from `idx_so_effects_poll`.
89
+ The PostgreSQL and MySQL queries do not change.
90
+
3
91
  ## 0.16.0 - 2026-09-23
4
92
 
5
93
  - Find a message whose reference a caller lost.
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. A query may not
171
- mutate state. The executor detects query mutation and fails the message. An
172
- observable is a named projection of state used by server rendering and realtime
173
- updates. Its durable broadcast row stores only an empty invalidation marker by
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. A result is
435
- stored for `sync` delivery only.
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
@@ -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, and dead-letter inspection/retry | 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 |
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
- Waiting again through `MessageReference#wait` reauthorizes the stored
19
- invocation as a message or query. Internal reminder, effect-callback, and
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.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
 
@@ -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.started",
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 " \
@@ -86,7 +86,8 @@ module SolidObjects
86
86
  # @rbs (Time) -> Array[Integer]
87
87
  def claimed_instance_ids(now)
88
88
  ClaimedMessage
89
- .joins(:instance)
89
+ .joins("CROSS JOIN #{Instance.table_name}")
90
+ .where("#{Instance.table_name}.id = #{ClaimedMessage.table_name}.instance_id")
90
91
  .where("#{Instance.table_name}.paused_at IS NULL")
91
92
  .where(available_lease_sql, now)
92
93
  .group(:instance_id)
@@ -459,7 +459,9 @@ module SolidObjects
459
459
 
460
460
  # @rbs () -> Hash[String, untyped]
461
461
  def observable_values
462
- guard_application_writes("observables") do
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
- guard_application_writes("observable.#{observable_name}") do
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(