solid_objects 0.13.3 → 0.14.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 +79 -0
- data/README.md +19 -11
- data/Rakefile +5 -0
- data/app/controllers/solid_objects/transmissions_controller.rb +32 -0
- data/config/routes.rb +1 -0
- data/docs/correctness.md +9 -0
- data/docs/operations.md +23 -1
- data/docs/research/solid_queue.md +2 -2
- data/docs/roadmap.md +25 -2
- data/docs/state-migrations.md +1 -1
- data/docs/transmission.md +198 -0
- data/examples/at_least_once/actor.rb +14 -0
- data/examples/at_least_once/boot.rb +47 -0
- data/examples/at_least_once/demo.rb +92 -0
- data/examples/at_least_once/effect_worker.rb +40 -0
- data/examples/at_least_once/sink.rb +27 -0
- data/lib/generators/solid_objects/templates/solid_objects.rb +17 -1
- data/lib/solid_objects/actor.rb +11 -0
- data/lib/solid_objects/actor_channel.rb +33 -3
- data/lib/solid_objects/configuration.rb +7 -1
- data/lib/solid_objects/engine.rb +4 -0
- data/lib/solid_objects/errors.rb +3 -0
- data/lib/solid_objects/transmission.rb +139 -0
- data/lib/solid_objects/version.rb +1 -1
- data/lib/solid_objects.rb +16 -0
- data/sig/generated/controllers/solid_objects/transmissions_controller.rbs +13 -0
- data/sig/generated/lib/solid_objects/actor.rbs +3 -0
- data/sig/generated/lib/solid_objects/actor_channel.rbs +10 -0
- data/sig/generated/lib/solid_objects/configuration.rbs +10 -2
- data/sig/generated/lib/solid_objects/errors.rbs +3 -0
- data/sig/generated/lib/solid_objects/transmission.rbs +34 -0
- data/sig/generated/lib/solid_objects.rbs +3 -0
- data/sig/support/framework.rbs +3 -0
- metadata +16 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d3b9abc40f188e1ea4964cfffca89e030990948c937a8aca0ec32c19830da272
|
|
4
|
+
data.tar.gz: 57707f08fc468d705202bbc3f20ddd60bfed4cd3d9153c58b7a06c981051ef93
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 203477264f47ba941dd4a29b08e00ce5e8ccce298fce361beeaed73f54d3fb1edbbd46f05733e44eaacb366301fc1a6128f084024976268e42ce302d6a7aab76
|
|
7
|
+
data.tar.gz: 1f82eeb81cd282bbf0cdabc3e4346376b21f5688997440a3f6790f3332f2762764d3a3e3a48778629702c0604480e4e0a6380a677d76a106d9f439c466302cf4
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,84 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.14.1 - 2026-08-24
|
|
4
|
+
|
|
5
|
+
- Register application actors in every process that boots the application.
|
|
6
|
+
The engine now loads the host application's `app/actors` directories from a
|
|
7
|
+
`to_prepare` hook, which previously only the `solid_objects start` process
|
|
8
|
+
did. An actor registers itself as a side effect of its class loading, so a
|
|
9
|
+
lazily loading web process began with an empty registry. `ActorChannel`
|
|
10
|
+
looks the actor up by name, and the resulting `UnknownActorType` reached the
|
|
11
|
+
rescue that rejects the subscription: a Cable subscription for a real actor
|
|
12
|
+
was rejected in any web process that had not yet rendered that actor, and
|
|
13
|
+
the page kept a card that never updated. `ComponentsController` resolves the
|
|
14
|
+
same way through `ActorSnapshot`. `Transmission.receive` already carried a
|
|
15
|
+
registry-miss retry for this reason, and it stays as a guard for a host that
|
|
16
|
+
reaches the gem without the engine.
|
|
17
|
+
- Report why a Cable subscription was rejected. Every reject path in
|
|
18
|
+
`ActorChannel#subscribed` now emits `solid_objects.subscription.rejected`
|
|
19
|
+
with a `reason`, the actor identity, and the `error_class` where an
|
|
20
|
+
exception caused it. Five conditions previously collapsed into one silent
|
|
21
|
+
`reject`, which is invisible from the browser and left nothing in the log to
|
|
22
|
+
distinguish an unregistered actor type from a tampered token. Exception
|
|
23
|
+
messages stay out of the payload, because a component or payload failure can
|
|
24
|
+
carry actor state.
|
|
25
|
+
|
|
26
|
+
- State where `async` waits when no worker runs. The `async` section of the
|
|
27
|
+
README and the runtime section of `docs/operations.md` now say that the
|
|
28
|
+
generator and the migrations start no role, so an application that serves
|
|
29
|
+
web requests alone leaves the message ready until
|
|
30
|
+
`bundle exec solid_objects start` runs the roles. The message is durable
|
|
31
|
+
and waits; it is not lost. `test/integration/background_pickup_test.rb`
|
|
32
|
+
pins it: the message reads `ready` and the actor state stays empty until a
|
|
33
|
+
worker runs. This matches solid-objects-js#22, which reported the same gap
|
|
34
|
+
for `runtime.run(signal)` in the Node package.
|
|
35
|
+
|
|
36
|
+
- Add `examples/at_least_once` and `bundle exec rake at_least_once`, an
|
|
37
|
+
executable proof that the at-least-once clause fires and that the
|
|
38
|
+
documented remedy absorbs it. One actor turn stages an effect that writes
|
|
39
|
+
to an external sink file. The first effect worker crashes between the sink
|
|
40
|
+
write and the acknowledgement, and a second worker reclaims the stale
|
|
41
|
+
effect after the liveness threshold and delivers again. With deduplication
|
|
42
|
+
off the sink reads 2, both deliveries carrying the same `context.id` at
|
|
43
|
+
attempts 1 and 2; with a guard on that id the sink reads 1. The actor state
|
|
44
|
+
commits exactly once in both runs. CI runs the demo in the SQLite job, and
|
|
45
|
+
`docs/correctness.md` links it from the handler idempotency section. This
|
|
46
|
+
mirrors `pnpm run test:at-least-once` in solid-objects-js.
|
|
47
|
+
|
|
48
|
+
## 0.14.0 - 2026-08-22
|
|
49
|
+
|
|
50
|
+
- Add `SolidObjects::Transmission.receive(envelope)`, the server ingest for
|
|
51
|
+
the browser transmit family in solid-objects-js. It validates a camelCase
|
|
52
|
+
transmit envelope, resolves the actor type through an optional
|
|
53
|
+
`resolve_actor_type:` proc, and enqueues one internal message with the
|
|
54
|
+
idempotency key `transmit:<effectId>`, so a replayed envelope applies
|
|
55
|
+
once. Malformed envelopes raise the new
|
|
56
|
+
`SolidObjects::InvalidTransmission`. Internal delivery skips
|
|
57
|
+
`authorize_message`, so the host application must authenticate the
|
|
58
|
+
request before it calls `receive`; see `docs/transmission.md` for the
|
|
59
|
+
controller boundary. On a registry miss under Rails, `receive` loads the
|
|
60
|
+
application's actor classes once and retries, because a lazy-loading web
|
|
61
|
+
process has no other reason to have loaded the target class. Golden
|
|
62
|
+
fixtures in `compatibility/transmit-envelopes.json` pin the wire contract
|
|
63
|
+
shared with the JS runtime.
|
|
64
|
+
- Add `Actor#transmit` and `SolidObjects.register_transmit`, the staging
|
|
65
|
+
side of the transmit family. `transmit.increment(amount:)` stages a
|
|
66
|
+
`solid-objects.transmit` effect in the same commit as the state change;
|
|
67
|
+
`register_transmit` drains staged effects into camelCase envelopes and
|
|
68
|
+
hands each to the delivery block, which raises to retry. A claimed
|
|
69
|
+
transmit effect delivers every undelivered sibling for its actor up to
|
|
70
|
+
its own mailbox sequence, oldest first, so per-actor order survives a
|
|
71
|
+
failed delivery, and the receiving side dedups on `transmit:<effectId>`.
|
|
72
|
+
A raw `emit "solid-objects.transmit"` with explicit `actorType` and
|
|
73
|
+
`actorId` targets a different actor, matching the JS staging surface.
|
|
74
|
+
- Mount `POST /solid_objects/transmit` in the engine, an ingest route
|
|
75
|
+
behind the new deny-by-default `authorize_transmission` policy. The
|
|
76
|
+
policy receives the parsed envelope and the controller, an unauthorized
|
|
77
|
+
envelope gets 403, and a permanently unappliable one gets 422, so a
|
|
78
|
+
sending outbox dead-letters it instead of retrying forever. The new
|
|
79
|
+
`transmission_actor_type_resolver` configuration maps diverged actor
|
|
80
|
+
type names for the engine route.
|
|
81
|
+
|
|
3
82
|
## 0.13.3 - 2026-08-18
|
|
4
83
|
|
|
5
84
|
- Stop loading `ActiveRecord::Base` when the gem is required. The engine now
|
data/README.md
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
# Solid Objects
|
|
2
2
|
|
|
3
|
-
[](https://github.com/cardmagic/solid-objects-ruby/actions/workflows/ci.yml)
|
|
4
4
|
|
|
5
5
|
**Self-hosted, distributed Durable Objects in Rails without a daemon using your existing SQL database.**
|
|
6
6
|
|
|
7
|
-
Solid Objects
|
|
8
|
-
durable state, serialized turns, alarms,
|
|
9
|
-
|
|
10
|
-
application already has,
|
|
7
|
+
Solid Objects ports the Durable Objects programming model to ordinary Rails
|
|
8
|
+
applications: addressable objects, durable state, serialized turns, alarms,
|
|
9
|
+
and live clients. It runs on the MySQL, PostgreSQL, or SQLite database that
|
|
10
|
+
the application already has, in the database-backed operating model of the
|
|
11
11
|
Solid family. No Redis, Cloudflare account, or separate actor service is
|
|
12
12
|
required.
|
|
13
13
|
|
|
@@ -113,7 +113,7 @@ maps those ideas into Rails:
|
|
|
113
113
|
| Storage deletion | Authorized `reference.destroy` |
|
|
114
114
|
| Cloudflare Workers platform | Your Rails processes and SQL database |
|
|
115
115
|
|
|
116
|
-
Rails already has
|
|
116
|
+
Rails already has tools for jobs, records, and realtime transport.
|
|
117
117
|
None of those primitives alone provides this complete stateful-object shape.
|
|
118
118
|
Solid Objects adds five capabilities:
|
|
119
119
|
|
|
@@ -662,6 +662,12 @@ message = order.async(
|
|
|
662
662
|
).submit
|
|
663
663
|
```
|
|
664
664
|
|
|
665
|
+
`async` needs a running actor worker. Installing the engine and migrating the
|
|
666
|
+
schema starts no role, so a process that only serves web requests leaves the
|
|
667
|
+
message ready. Nothing is lost. The message waits until
|
|
668
|
+
`bundle exec solid_objects start` runs the roles. See
|
|
669
|
+
[Worker requirements](#worker-requirements) for the feature-by-role table.
|
|
670
|
+
|
|
665
671
|
Use `available_at:` to spread bulk work or delay one message:
|
|
666
672
|
|
|
667
673
|
```ruby
|
|
@@ -1117,10 +1123,12 @@ and marks process rows stopped on graceful shutdown. A hard-killed worker's
|
|
|
1117
1123
|
claimed turn is recovered after its process heartbeat or activation lease
|
|
1118
1124
|
becomes stale.
|
|
1119
1125
|
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
an initializer.
|
|
1126
|
+
The engine loads actors from the host application's `app/actors` directories
|
|
1127
|
+
through Rails' main autoloader, in every process that boots the application.
|
|
1128
|
+
This works when eager loading is disabled and does not require actor
|
|
1129
|
+
references in an initializer. A web process therefore resolves an actor by
|
|
1130
|
+
name for a Cable subscription or a component render without having loaded that
|
|
1131
|
+
class through an earlier request.
|
|
1124
1132
|
|
|
1125
1133
|
See the [operations guide](docs/operations.md) for monitoring, reconciliation,
|
|
1126
1134
|
shutdown, retention, and backup guidance.
|
|
@@ -1273,7 +1281,7 @@ for staged cutovers.
|
|
|
1273
1281
|
| --- | --- |
|
|
1274
1282
|
| Cloudflare Durable Objects | Solid Objects ports the named, stateful, serialized-object model to Ruby and Rails. It uses your SQL database and Rails workers rather than Cloudflare's globally distributed serverless runtime, placement, and storage APIs. |
|
|
1275
1283
|
| Active Job | Jobs are independent work units. Solid Objects adds addressable identity, durable state, explicit per-identity order, activation leases, and fencing. |
|
|
1276
|
-
| Solid Queue | Solid Queue is
|
|
1284
|
+
| Solid Queue | Solid Queue is a database backend for Active Job. Its concurrency controls cap overlap but do not guarantee order. Solid Objects provides actor mailboxes, state, fencing, per-identity reminders, and state-driven views. |
|
|
1277
1285
|
| Action Cable | Cable transports transient realtime messages. Solid Objects owns durable state and work; Cable is an optional delivery path for committed observable projections. |
|
|
1278
1286
|
| Orleans | Orleans provides the virtual-actor lineage behind the model, with grains, reminders, and activation lifecycle. Solid Objects is a smaller Rails-native runtime and does not match Orleans clustering or placement breadth. |
|
|
1279
1287
|
| Active Record service object | A service object runs directly against records. Solid Objects adds durable asynchronous ordering, retries, activation fencing, reminders, and outboxes at greater operational cost. |
|
data/Rakefile
CHANGED
|
@@ -32,6 +32,11 @@ task :steep do
|
|
|
32
32
|
sh "bundle exec steep check"
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
+
desc "Prove the at-least-once clause by crashing an effect worker at a sink"
|
|
36
|
+
task :at_least_once do
|
|
37
|
+
sh "bundle exec ruby examples/at_least_once/demo.rb"
|
|
38
|
+
end
|
|
39
|
+
|
|
35
40
|
desc "Scan the Rails engine for security warnings"
|
|
36
41
|
task :security do
|
|
37
42
|
sh "bundle exec brakeman --force --no-pager -q ."
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
require "action_controller/api"
|
|
4
|
+
|
|
5
|
+
module SolidObjects
|
|
6
|
+
class TransmissionsController < ActionController::API
|
|
7
|
+
# @rbs () -> void
|
|
8
|
+
def create
|
|
9
|
+
envelope = JSON.parse(request.body.read)
|
|
10
|
+
return head :forbidden unless authorized_transmission?(envelope)
|
|
11
|
+
|
|
12
|
+
Transmission.receive(
|
|
13
|
+
envelope,
|
|
14
|
+
resolve_actor_type: SolidObjects.configuration.transmission_actor_type_resolver
|
|
15
|
+
)
|
|
16
|
+
head :ok
|
|
17
|
+
rescue JSON::ParserError, InvalidTransmission, UnknownActorType, UnknownMessage,
|
|
18
|
+
PayloadTooLarge, IdempotencyConflict
|
|
19
|
+
head :unprocessable_entity
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
# @rbs (untyped) -> bool
|
|
25
|
+
def authorized_transmission?(envelope)
|
|
26
|
+
SolidObjects.configuration.authorize_transmission.call(
|
|
27
|
+
envelope:,
|
|
28
|
+
authorization_context: self
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
data/config/routes.rb
CHANGED
data/docs/correctness.md
CHANGED
|
@@ -93,6 +93,15 @@ end
|
|
|
93
93
|
The guard prevents a repeated state transition. The effect consumer still
|
|
94
94
|
deduplicates with `context.id`.
|
|
95
95
|
|
|
96
|
+
This clause is observable, not decorative. `bundle exec rake at_least_once`
|
|
97
|
+
crashes an effect worker between the external sink write and the
|
|
98
|
+
acknowledgement, restarts one after the liveness threshold, and shows the sink
|
|
99
|
+
reading 2 with deduplication off. Both deliveries carry the same `context.id`
|
|
100
|
+
at attempts 1 and 2. A guard on that id absorbs the same duplicate and the
|
|
101
|
+
sink reads 1. The actor state commits exactly once in both runs. The source is
|
|
102
|
+
`examples/at_least_once/`; solid-objects-js runs the same proof with
|
|
103
|
+
`pnpm run test:at-least-once`.
|
|
104
|
+
|
|
96
105
|
## Atomic boundaries
|
|
97
106
|
|
|
98
107
|
The following are atomic:
|
data/docs/operations.md
CHANGED
|
@@ -31,12 +31,25 @@ Start all configured roles:
|
|
|
31
31
|
bundle exec solid_objects start
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
-
The
|
|
34
|
+
The generator and the migrations prepare the database and start nothing. A
|
|
35
|
+
process claims ready messages only after this command starts its roles, so an
|
|
36
|
+
application that serves web requests alone leaves every `async` message ready.
|
|
37
|
+
The message is durable and waits for the first process that runs the roles. A
|
|
38
|
+
direct call or an explicit `sync` needs no running role, because the caller's
|
|
39
|
+
own path executes it.
|
|
40
|
+
|
|
41
|
+
The engine loads the host application's `app/actors` directories in every
|
|
42
|
+
process that boots the application, and the command repeats that load before
|
|
35
43
|
starting any runtime role, even when Rails eager loading is disabled. Actors in
|
|
36
44
|
the conventional directory do not need initializer references. The targeted
|
|
37
45
|
loader participates in Rails preparation callbacks so a development reload can
|
|
38
46
|
replace a registered actor class without loading unrelated application code.
|
|
39
47
|
|
|
48
|
+
An actor registers itself as its class loads, and a web process resolves
|
|
49
|
+
actors by name for Cable subscriptions and component renders. Loading them in
|
|
50
|
+
every process is what lets a freshly booted web process serve a live card for
|
|
51
|
+
an actor no request in that process has rendered yet.
|
|
52
|
+
|
|
40
53
|
Inspect process records and clean stale ownership:
|
|
41
54
|
|
|
42
55
|
```bash
|
|
@@ -187,6 +200,15 @@ transaction rejection, commit-action start/completion/failure, effect and
|
|
|
187
200
|
broadcast enqueue/completion, reminder enqueue, actor destruction/expiration,
|
|
188
201
|
retention pruning, process cleanup, and supervisor lifecycle.
|
|
189
202
|
|
|
203
|
+
`solid_objects.subscription.rejected` reports a rejected Cable subscription.
|
|
204
|
+
A rejection closes the socket and leaves the page holding a stale card, and
|
|
205
|
+
the browser cannot say which of the conditions applied. The event carries the
|
|
206
|
+
`reason`, the actor identity, and the `error_class` where an exception caused
|
|
207
|
+
it. The reason is one of `unregistered_actor_type`, `invalid_stream_token`,
|
|
208
|
+
`invalid_component_token`, `malformed_component_registration`,
|
|
209
|
+
`missing_subscription_parameter`, or `unauthorized`. Exception messages are
|
|
210
|
+
excluded, because a component or payload failure can carry actor state.
|
|
211
|
+
|
|
190
212
|
`solid_objects.reminder.replaced` reports a `schedule` call that moved an alarm
|
|
191
213
|
already armed under the same name on the same actor, carrying the actor
|
|
192
214
|
identity, reminder `name`, `previous_run_at`, and `next_run_at`. Reminders are
|
|
@@ -526,8 +526,8 @@ Source: [Cloudflare Durable Objects overview](https://developers.cloudflare.com/
|
|
|
526
526
|
- Solid Queue findings are tied to v1.6.0. Earlier releases did not have all current async/fiber supervision and concurrency finalization behavior.
|
|
527
527
|
- PostgreSQL documentation inspected was current PostgreSQL 18 documentation. `SKIP LOCKED` has existed since PostgreSQL 9.5, but Solid Objects supports PostgreSQL 14 and newer.
|
|
528
528
|
- MySQL documentation inspected was MySQL 8.4. Solid Objects supports MySQL 8.0 and newer with InnoDB.
|
|
529
|
-
- SQLite documentation inspected covers current SQLite behavior. Solid Objects
|
|
530
|
-
- Rails documentation and source inspected cover Rails 8.1. Solid Objects requires Rails
|
|
529
|
+
- SQLite documentation inspected covers current SQLite behavior. Solid Objects supports SQLite 3.35 or newer, the oldest server version the adapter accepts.
|
|
530
|
+
- Rails documentation and source inspected cover Rails 8.1. Solid Objects requires Rails 7.1 or newer. Rails 8 changed the SQLite adapter's default write transaction from deferred to immediate, and the test suite also passes on Rails 7.1 and 7.2 with the deferred default. Rails 7.0 stays out of range because its SQLite adapter requires sqlite3 1.4, and the busy-handler control this gem depends on arrived in sqlite3 2.x.
|
|
531
531
|
- Orleans documentation describes current Orleans behavior, not a compatibility promise for this Ruby implementation.
|
|
532
532
|
- `LISTEN/NOTIFY` and Action Cable are optimizations and delivery channels, never durable truth.
|
|
533
533
|
- A separate actor database is compatible only when all rows participating in an atomic actor commit, including message, state, effects, and broadcasts, live in that same actor database.
|
data/docs/roadmap.md
CHANGED
|
@@ -72,6 +72,20 @@
|
|
|
72
72
|
Rails 7.1 and 7.2 is unmeasured against those servers. Rails 7.0 is out of
|
|
73
73
|
range because its SQLite adapter requires `sqlite3 ~> 1.4`, and this gem needs
|
|
74
74
|
the busy-handler control that arrived in `sqlite3` 2.x
|
|
75
|
+
- The transmit family, both sides. `SolidObjects::Transmission.receive` is
|
|
76
|
+
the ingest: envelope validation, actor type resolution with a per-call
|
|
77
|
+
`resolve_actor_type:` escape hatch, and an internal idempotent enqueue
|
|
78
|
+
keyed `transmit:<effectId>`. `Actor#transmit` and
|
|
79
|
+
`SolidObjects.register_transmit` are the staging side: a transactional
|
|
80
|
+
`solid-objects.transmit` effect and a drain that delivers every
|
|
81
|
+
undelivered sibling for the actor up to the claimed effect's mailbox
|
|
82
|
+
sequence, oldest first, so per-actor order survives a failed delivery.
|
|
83
|
+
The wire contract is pinned by golden fixtures in
|
|
84
|
+
`compatibility/transmit-envelopes.json`. The engine mounts
|
|
85
|
+
`POST /solid_objects/transmit` behind a deny-by-default
|
|
86
|
+
`authorize_transmission` policy with a configurable actor type resolver.
|
|
87
|
+
Bidirectional replication as a declared surface, with echo suppression,
|
|
88
|
+
is not implemented
|
|
75
89
|
- A JavaScript suite covering every browser module, run in CI with Node's test
|
|
76
90
|
runner and jsdom, plus a browser suite running the same modules against real
|
|
77
91
|
Chromium and a real Turbo build, with every GitHub Actions reference pinned to
|
|
@@ -112,7 +126,13 @@
|
|
|
112
126
|
untested end to end, which is how a raising payload block came to reject the
|
|
113
127
|
subscription; it is now covered and confined, and the payload authorization
|
|
114
128
|
context is resolved through `payload_authorization_context` rather than
|
|
115
|
-
handing the block a raw Cable connection.
|
|
129
|
+
handing the block a raw Cable connection. Actor registration in a web process
|
|
130
|
+
was assumed rather than arranged: an actor registered only as a side effect
|
|
131
|
+
of its class loading, and only the worker CLI loaded the host's `app/actors`,
|
|
132
|
+
so a lazily loading web process rejected subscriptions for actors it could
|
|
133
|
+
serve until some earlier request happened to load the class. The engine now
|
|
134
|
+
loads them in every process, and a rejected subscription reports which
|
|
135
|
+
condition caused it instead of closing the socket silently.
|
|
116
136
|
- Backpressure: mailbox/payload/state/result caps and fair yields exist;
|
|
117
137
|
distributed per-actor rate limits and global admission control do not.
|
|
118
138
|
- Administration: `SolidObjects::Web` is a mountable Rack dashboard covering
|
|
@@ -129,7 +149,10 @@
|
|
|
129
149
|
of who pressed what, and bulk-safe tools: retry is one dead letter at a time,
|
|
130
150
|
because `DeadLetterManager` exposes no bulk operation. Pause is an operator
|
|
131
151
|
brake and not a stop, since a pass already in flight finishes its turn and a
|
|
132
|
-
synchronous caller waiting on a paused instance times out.
|
|
152
|
+
synchronous caller waiting on a paused instance times out. Retry also only
|
|
153
|
+
exists for message dead letters: a dead effect or broadcast has no retry
|
|
154
|
+
API, which matters for transmit effects because a dead one is a lost
|
|
155
|
+
replay until an operator returns its row to pending. The page cost was
|
|
133
156
|
reasoned about rather than measured: the summary bar issues a fixed set of
|
|
134
157
|
indexed aggregate queries per page, which is why `HEAD /` exists for uptime
|
|
135
158
|
monitors, but no dashboard latency has been benchmarked against a large
|
data/docs/state-migrations.md
CHANGED
|
@@ -47,6 +47,6 @@ A safe destructive rollout normally uses:
|
|
|
47
47
|
Never update actor JSON in a bulk SQL migration. Use actor messages so fencing,
|
|
48
48
|
ordering, observables, and outboxes remain intact.
|
|
49
49
|
|
|
50
|
-
This guide covers
|
|
50
|
+
This guide covers changes to state that Solid Objects already owns. To move
|
|
51
51
|
existing Redis, key-value, or relational state into actors without downtime,
|
|
52
52
|
use the [legacy-state migration cookbook](migrating-existing-state.md).
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# The transmit family
|
|
2
|
+
|
|
3
|
+
The transmit family replays one runtime's actor operations onto another
|
|
4
|
+
runtime over a shared wire contract. The Ruby gem holds both sides:
|
|
5
|
+
|
|
6
|
+
- `Actor#transmit` and `SolidObjects.register_transmit` stage and deliver
|
|
7
|
+
envelopes. This is the sending side.
|
|
8
|
+
- `SolidObjects::Transmission.receive(envelope)` ingests envelopes. This is
|
|
9
|
+
the receiving side.
|
|
10
|
+
|
|
11
|
+
[solid-objects-js](https://github.com/cardmagic/solid-objects-js) holds the
|
|
12
|
+
same two sides for the browser and Node. A browser actor stages a transmit
|
|
13
|
+
intent with `this.transmit().increment({ amount })` in the same transaction
|
|
14
|
+
as its state change; its effect worker drains that outbox with
|
|
15
|
+
at-least-once delivery, per-actor order, and retry backoff, and posts one
|
|
16
|
+
JSON envelope per effect to a route the host application owns. A Rails
|
|
17
|
+
actor does the same with `transmit.increment(amount:)`. Either ingest
|
|
18
|
+
accepts either sender, so Rails-to-Rails, Rails-to-Node, Node-to-Rails,
|
|
19
|
+
and browser-to-Rails replication all ride one contract.
|
|
20
|
+
|
|
21
|
+
## The sending side
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
class Counter < SolidObjects::Actor
|
|
25
|
+
actor_type "counters"
|
|
26
|
+
|
|
27
|
+
attribute :count, default: 0
|
|
28
|
+
|
|
29
|
+
def increment(amount: 1)
|
|
30
|
+
self.count += amount
|
|
31
|
+
transmit.increment(amount:)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
SolidObjects.register_transmit do |envelope|
|
|
36
|
+
DeliverToUpstream.call(envelope)
|
|
37
|
+
end
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`transmit` returns the same fluent dispatcher `schedule` returns. It stages
|
|
41
|
+
one `solid-objects.transmit` effect in the same commit as the state change,
|
|
42
|
+
targeting the same operation on the same actor in the receiving runtime. For
|
|
43
|
+
a different target, stage the effect directly:
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
emit "solid-objects.transmit",
|
|
47
|
+
operation: "increment",
|
|
48
|
+
arguments: { amount: 2 },
|
|
49
|
+
actorType: "other-counters",
|
|
50
|
+
actorId: "counter-1"
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`SolidObjects.register_transmit(&deliver)` registers the drain handler for
|
|
54
|
+
that effect. The block receives one camelCase envelope per staged effect.
|
|
55
|
+
Raise inside the block while the upstream is unreachable; the effect
|
|
56
|
+
retries with backoff and dead-letters on exhaustion, like any other effect.
|
|
57
|
+
|
|
58
|
+
The drain keeps per-actor order across failures: a claimed transmit effect
|
|
59
|
+
delivers every undelivered sibling for its actor up to its own mailbox
|
|
60
|
+
sequence, oldest first. The receiving side dedups on `transmit:<effectId>`,
|
|
61
|
+
so a redelivered envelope applies once.
|
|
62
|
+
|
|
63
|
+
Delivery is at-least-once by design, and the drain accepts redundant sends
|
|
64
|
+
as the price of ordering without cross-worker coordination. A drained
|
|
65
|
+
sibling's own effect row stays pending, because completing it would
|
|
66
|
+
require taking over another worker's claim; when its own claim runs, it
|
|
67
|
+
delivers again and the receiving side drops the replay. The same is true
|
|
68
|
+
when two workers claim effects for one actor concurrently. Both runtimes
|
|
69
|
+
share this behavior, and the Ruby suite pins it with a race test: order
|
|
70
|
+
holds, duplicates apply nothing, and every effect completes.
|
|
71
|
+
|
|
72
|
+
## Retry budget and offline tolerance
|
|
73
|
+
|
|
74
|
+
A raised delivery follows the effect retry policy: `max_attempts` (default
|
|
75
|
+
5) and `retry_delay` (default `2 ** (attempt - 1)` seconds, capped at 60).
|
|
76
|
+
The defaults give roughly fifteen seconds of offline tolerance before an
|
|
77
|
+
envelope dead-letters. An application that transmits across real outages
|
|
78
|
+
must raise both:
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
SolidObjects.configure do |configuration|
|
|
82
|
+
configuration.max_attempts = 30
|
|
83
|
+
configuration.retry_delay = ->(attempt) { [ 2**(attempt - 1), 300 ].min.to_f }
|
|
84
|
+
end
|
|
85
|
+
```
|
|
86
|
+
|
|
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.
|
|
92
|
+
|
|
93
|
+
## Wire contract
|
|
94
|
+
|
|
95
|
+
The JS side owns the envelope format. The Ruby ingest accepts it verbatim.
|
|
96
|
+
|
|
97
|
+
- Keys arrive camelCase: `effectId`, `actorType`, `actorId`, `operation`,
|
|
98
|
+
and an optional `arguments` object. There is no snake_case dialect.
|
|
99
|
+
- The idempotency key is `transmit:<effectId>`, byte-identical to the JS
|
|
100
|
+
server ingest. A replayed envelope applies once.
|
|
101
|
+
- Delivery is at-least-once and per-actor ordered by the browser's drain.
|
|
102
|
+
The server preserves mailbox order and adds no ordering of its own.
|
|
103
|
+
|
|
104
|
+
`compatibility/transmit-envelopes.json` pins the contract. Both runtimes
|
|
105
|
+
run a consuming test against the same fixture file.
|
|
106
|
+
|
|
107
|
+
## What `receive` does
|
|
108
|
+
|
|
109
|
+
1. It validates the envelope shape. A malformed envelope raises
|
|
110
|
+
`SolidObjects::InvalidTransmission`.
|
|
111
|
+
2. It resolves the actor type and looks it up in the registry. On a miss
|
|
112
|
+
under Rails it loads the application's actor classes once and retries,
|
|
113
|
+
because a lazy-loading process has no other reason to have loaded the
|
|
114
|
+
target class. A type that is still unknown raises
|
|
115
|
+
`SolidObjects::UnknownActorType`. An undeclared operation raises
|
|
116
|
+
`SolidObjects::UnknownMessage`.
|
|
117
|
+
3. It enqueues one internal message with the idempotency key
|
|
118
|
+
`transmit:<effectId>`. Oversized arguments raise
|
|
119
|
+
`SolidObjects::PayloadTooLarge` before persistence.
|
|
120
|
+
|
|
121
|
+
The enqueue uses `delivery_mode: "internal"`, the same mode the effect
|
|
122
|
+
executor uses. Internal delivery skips `authorize_message` by construction.
|
|
123
|
+
The host application must authenticate the request before it calls
|
|
124
|
+
`receive`.
|
|
125
|
+
|
|
126
|
+
## The engine route
|
|
127
|
+
|
|
128
|
+
The engine mounts `POST /solid_objects/transmit` (under wherever the host
|
|
129
|
+
mounts `SolidObjects::Engine`). It parses the body, authorizes it through
|
|
130
|
+
`authorize_transmission`, and passes it to `Transmission.receive` with the
|
|
131
|
+
configured `transmission_actor_type_resolver`. The policy denies by
|
|
132
|
+
default, because the ingest skips `authorize_message` by design; an
|
|
133
|
+
unauthorized envelope gets 403, and an envelope the server can never apply
|
|
134
|
+
gets 422:
|
|
135
|
+
|
|
136
|
+
```ruby
|
|
137
|
+
SolidObjects.configure do |configuration|
|
|
138
|
+
configuration.authorize_transmission = lambda do |envelope:, authorization_context:|
|
|
139
|
+
ActiveSupport::SecurityUtils.secure_compare(
|
|
140
|
+
authorization_context.request.headers["Authorization"].to_s,
|
|
141
|
+
"Bearer #{Rails.application.credentials.transmit_token}"
|
|
142
|
+
)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
The policy receives the parsed envelope and the controller as
|
|
148
|
+
`authorization_context:`, so it can bind `actorType` and `actorId` to the
|
|
149
|
+
authenticated caller, not only check a shared token. Rate limits stay with
|
|
150
|
+
the host (Rack::Attack or the proxy), the same boundary the dashboard
|
|
151
|
+
draws.
|
|
152
|
+
|
|
153
|
+
## A hand-rolled route
|
|
154
|
+
|
|
155
|
+
An application that wants its own controller keeps the same shape:
|
|
156
|
+
|
|
157
|
+
```ruby
|
|
158
|
+
class TransmitController < ApplicationController
|
|
159
|
+
skip_forgery_protection
|
|
160
|
+
|
|
161
|
+
def create
|
|
162
|
+
head :forbidden and return unless authenticated_device?
|
|
163
|
+
|
|
164
|
+
SolidObjects::Transmission.receive(JSON.parse(request.body.read))
|
|
165
|
+
head :ok
|
|
166
|
+
rescue SolidObjects::InvalidTransmission, SolidObjects::UnknownActorType,
|
|
167
|
+
SolidObjects::UnknownMessage, SolidObjects::PayloadTooLarge,
|
|
168
|
+
SolidObjects::IdempotencyConflict, JSON::ParserError
|
|
169
|
+
head :unprocessable_entity
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Return 422 for an envelope the server can never apply. The browser outbox
|
|
175
|
+
dead-letters that effect instead of retrying it forever. Return a 5xx for a
|
|
176
|
+
transient server fault, so the browser retries with backoff.
|
|
177
|
+
`SolidObjects::IdempotencyConflict` belongs in the 422 list: it means the
|
|
178
|
+
effect id was replayed with a different invocation, and no retry can ever
|
|
179
|
+
make that envelope apply.
|
|
180
|
+
|
|
181
|
+
## Actor type mapping
|
|
182
|
+
|
|
183
|
+
When both runtimes use the same actor type strings, no configuration is
|
|
184
|
+
needed. When the names diverge, pass `resolve_actor_type:` per call:
|
|
185
|
+
|
|
186
|
+
```ruby
|
|
187
|
+
SolidObjects::Transmission.receive(
|
|
188
|
+
envelope,
|
|
189
|
+
resolve_actor_type: ->(actor_type) { actor_type.sub("browser-", "server-") }
|
|
190
|
+
)
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## Scope
|
|
194
|
+
|
|
195
|
+
Bidirectional replication as a first-class surface, where two runtimes
|
|
196
|
+
declare a replica pair and echo suppression keeps a replayed operation
|
|
197
|
+
from transmitting back, is a separate feature. The transmit family gives
|
|
198
|
+
it the mechanism; the declaration API does not exist yet.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "active_record"
|
|
5
|
+
require "solid_objects"
|
|
6
|
+
|
|
7
|
+
# Boots a standalone runtime against a shared SQLite file, the way the
|
|
8
|
+
# demo's parent and its crashing children all attach to one database.
|
|
9
|
+
module AtLeastOnceBoot
|
|
10
|
+
ROOT = File.expand_path("../..", __dir__)
|
|
11
|
+
|
|
12
|
+
# @rbs (String database_path) -> void
|
|
13
|
+
def self.call(database_path)
|
|
14
|
+
ActiveRecord::Base.establish_connection(
|
|
15
|
+
adapter: "sqlite3",
|
|
16
|
+
database: database_path,
|
|
17
|
+
pool: 5,
|
|
18
|
+
timeout: 5_000
|
|
19
|
+
)
|
|
20
|
+
ActiveRecord::Migration.verbose = false
|
|
21
|
+
migrate unless ActiveRecord::Base.connection.table_exists?("solid_objects_instances")
|
|
22
|
+
|
|
23
|
+
require "solid_objects/database_adapter"
|
|
24
|
+
%w[
|
|
25
|
+
record process instance message ready_message claimed_message
|
|
26
|
+
reminder effect broadcast dead_letter
|
|
27
|
+
].each { |model| require File.join(ROOT, "app/models/solid_objects", model) }
|
|
28
|
+
|
|
29
|
+
SolidObjects.configuration.authorize_message = ->(**) { true }
|
|
30
|
+
SolidObjects.configuration.authorize_query = ->(**) { true }
|
|
31
|
+
SolidObjects.configuration.polling_interval = 0.01
|
|
32
|
+
SolidObjects.configuration.process_heartbeat_interval = 0.075
|
|
33
|
+
SolidObjects.configuration.process_alive_threshold = 0.3
|
|
34
|
+
SolidObjects.configuration.lease_duration = 0.25
|
|
35
|
+
SolidObjects.configuration.lease_renewal_interval = 0.05
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @rbs () -> void
|
|
39
|
+
def self.migrate
|
|
40
|
+
require File.join(ROOT, "db/migrate/20260805000000_create_solid_objects_tables")
|
|
41
|
+
require File.join(ROOT, "db/migrate/20260806000000_add_state_revision_to_solid_objects_instances")
|
|
42
|
+
require File.join(ROOT, "db/migrate/20260813000000_rename_message_dispatch_columns")
|
|
43
|
+
CreateSolidObjectsTables.new.migrate(:up)
|
|
44
|
+
AddStateRevisionToSolidObjectsInstances.new.migrate(:up)
|
|
45
|
+
RenameMessageDispatchColumns.new.migrate(:up)
|
|
46
|
+
end
|
|
47
|
+
end
|