Skip to content

Commit ef6182e

Browse files
committed
fix: load application actors on an ingest miss
Manual QA in a fresh Rails app found the primary use case broken in development: a controller calling Transmission.receive got UnknownActorType for every envelope. The ingest looks actors up by string, and a string cannot trigger the autoload that referencing the class constant does, so a lazy-loading web process has an empty registry. Only the CLI installed ApplicationActorLoader. receive now loads the application's actor classes once on a registry miss and retries, through an injectable actor_loader: that follows the mailbox: injection pattern. The first fix attempt also raised NameError in the web process because application_actor_loader was required only by the CLI; the gem now requires it, and the load contract ledger drops its used-only-by-the-CLI row. QA also showed a conflicting replay returning 500 through the documented controller, which would make a browser outbox retry a permanently unappliable envelope forever. IdempotencyConflict joins the documented 422 rescue list. The transmit docs gain the effect retry budget: the defaults dead-letter an envelope after roughly fifteen seconds offline, a dead effect has no retry API, and the roadmap records that limitation.
1 parent d27e01d commit ef6182e

8 files changed

Lines changed: 87 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@
1111
`SolidObjects::InvalidTransmission`. Internal delivery skips
1212
`authorize_message`, so the host application must authenticate the
1313
request before it calls `receive`; see `docs/transmission.md` for the
14-
controller boundary. Golden fixtures in
15-
`compatibility/transmit-envelopes.json` pin the wire contract shared with
16-
the JS runtime.
14+
controller boundary. On a registry miss under Rails, `receive` loads the
15+
application's actor classes once and retries, because a lazy-loading web
16+
process has no other reason to have loaded the target class. Golden
17+
fixtures in `compatibility/transmit-envelopes.json` pin the wire contract
18+
shared with the JS runtime.
1719
- Add `Actor#transmit` and `SolidObjects.register_transmit`, the staging
1820
side of the transmit family. `transmit.increment(amount:)` stages a
1921
`solid-objects.transmit` effect in the same commit as the state change;

docs/roadmap.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,10 @@
140140
of who pressed what, and bulk-safe tools: retry is one dead letter at a time,
141141
because `DeadLetterManager` exposes no bulk operation. Pause is an operator
142142
brake and not a stop, since a pass already in flight finishes its turn and a
143-
synchronous caller waiting on a paused instance times out. The page cost was
143+
synchronous caller waiting on a paused instance times out. Retry also only
144+
exists for message dead letters: a dead effect or broadcast has no retry
145+
API, which matters for transmit effects because a dead one is a lost
146+
replay until an operator returns its row to pending. The page cost was
144147
reasoned about rather than measured: the summary bar issues a fixed set of
145148
indexed aggregate queries per page, which is why `HEAD /` exists for uptime
146149
monitors, but no dashboard latency has been benchmarked against a large

docs/transmission.md

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,27 @@ delivers every undelivered sibling for its actor up to its own mailbox
6060
sequence, oldest first. The receiving side dedups on `transmit:<effectId>`,
6161
so a redelivered envelope applies once.
6262

63+
## Retry budget and offline tolerance
64+
65+
A raised delivery follows the effect retry policy: `max_attempts` (default
66+
5) and `retry_delay` (default `2 ** (attempt - 1)` seconds, capped at 60).
67+
The defaults give roughly fifteen seconds of offline tolerance before an
68+
envelope dead-letters. An application that transmits across real outages
69+
must raise both:
70+
71+
```ruby
72+
SolidObjects.configure do |configuration|
73+
configuration.max_attempts = 30
74+
configuration.retry_delay = ->(attempt) { [ 2**(attempt - 1), 300 ].min.to_f }
75+
end
76+
```
77+
78+
These settings apply to every effect, not only transmits. A dead transmit
79+
effect has no retry API; the dashboard lists it, and recovery means
80+
returning its row to `pending` with a cleared `attempt_count`. Order
81+
survives that recovery, because the drain orders by mailbox sequence, not
82+
by retry time.
83+
6384
## Wire contract
6485

6586
The JS side owns the envelope format. The Ruby ingest accepts it verbatim.
@@ -78,9 +99,12 @@ run a consuming test against the same fixture file.
7899

79100
1. It validates the envelope shape. A malformed envelope raises
80101
`SolidObjects::InvalidTransmission`.
81-
2. It resolves the actor type and looks it up in the registry. An unknown
82-
type raises `SolidObjects::UnknownActorType`. An undeclared operation
83-
raises `SolidObjects::UnknownMessage`.
102+
2. It resolves the actor type and looks it up in the registry. On a miss
103+
under Rails it loads the application's actor classes once and retries,
104+
because a lazy-loading process has no other reason to have loaded the
105+
target class. A type that is still unknown raises
106+
`SolidObjects::UnknownActorType`. An undeclared operation raises
107+
`SolidObjects::UnknownMessage`.
84108
3. It enqueues one internal message with the idempotency key
85109
`transmit:<effectId>`. Oversized arguments raise
86110
`SolidObjects::PayloadTooLarge` before persistence.
@@ -105,7 +129,8 @@ class TransmitController < ApplicationController
105129
SolidObjects::Transmission.receive(JSON.parse(request.body.read))
106130
head :ok
107131
rescue SolidObjects::InvalidTransmission, SolidObjects::UnknownActorType,
108-
SolidObjects::UnknownMessage, SolidObjects::PayloadTooLarge, JSON::ParserError
132+
SolidObjects::UnknownMessage, SolidObjects::PayloadTooLarge,
133+
SolidObjects::IdempotencyConflict, JSON::ParserError
109134
head :unprocessable_entity
110135
end
111136
end
@@ -114,6 +139,9 @@ end
114139
Return 422 for an envelope the server can never apply. The browser outbox
115140
dead-letters that effect instead of retrying it forever. Return a 5xx for a
116141
transient server fault, so the browser retries with backoff.
142+
`SolidObjects::IdempotencyConflict` belongs in the 422 list: it means the
143+
effect id was replayed with a different invocation, and no retry can ever
144+
make that envelope apply.
117145

118146
## Actor type mapping
119147

lib/solid_objects.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
# was reachable only through the caller path, so requiring the gem was not
6464
# enough to run a role that uses it.
6565
require "solid_objects/mailbox"
66+
require "solid_objects/application_actor_loader"
6667
require "solid_objects/transmission"
6768
require "solid_objects/worker"
6869
require "solid_objects/effect_executor"

lib/solid_objects/transmission.rb

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,12 @@ module Transmission
88
UNDELIVERED_STATUSES = %w[pending processing].freeze
99

1010
class << self
11-
# @rbs (untyped envelope, ?resolve_actor_type: ^(String) -> (String | Symbol), ?mailbox: Mailbox) -> MessageReference
12-
def receive(envelope, resolve_actor_type: :itself.to_proc, mailbox: Mailbox.new)
11+
# @rbs (untyped envelope, ?resolve_actor_type: ^(String) -> (String | Symbol), ?mailbox: Mailbox, ?actor_loader: ^() -> bool) -> MessageReference
12+
def receive(envelope, resolve_actor_type: :itself.to_proc, mailbox: Mailbox.new, actor_loader: method(:load_application_actors))
1313
validate!(envelope)
1414

1515
actor_type = resolve_actor_type.call(envelope["actorType"]).to_s
16-
actor_class = SolidObjects.registry.fetch(actor_type)
16+
actor_class = fetch_actor_class(actor_type, actor_loader)
1717
operation = envelope["operation"].to_sym
1818
unless actor_class.definition.messages.key?(operation)
1919
raise UnknownMessage, "unknown operation #{envelope["operation"].inspect}"
@@ -44,6 +44,23 @@ def deliver_through(effect_name:, arguments:, context:, deliver:)
4444

4545
private
4646

47+
# @rbs (String, ^() -> bool) -> Class
48+
def fetch_actor_class(actor_type, actor_loader)
49+
SolidObjects.registry.fetch(actor_type)
50+
rescue UnknownActorType
51+
raise unless actor_loader.call
52+
53+
SolidObjects.registry.fetch(actor_type)
54+
end
55+
56+
# @rbs () -> bool
57+
def load_application_actors
58+
return false unless defined?(Rails.application) && Rails.application
59+
60+
ApplicationActorLoader.new.call
61+
true
62+
end
63+
4764
# @rbs (Hash[String, untyped], effect_id: String, actor_type: String, actor_id: String) -> Hash[String, untyped]
4865
def staged_envelope(arguments, effect_id:, actor_type:, actor_id:)
4966
operation = arguments["operation"]

sig/generated/lib/solid_objects/transmission.rbs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,18 @@ module SolidObjects
1010

1111
UNDELIVERED_STATUSES: untyped
1212

13-
# @rbs (untyped envelope, ?resolve_actor_type: ^(String) -> (String | Symbol), ?mailbox: Mailbox) -> MessageReference
14-
def self.receive: (untyped envelope, ?resolve_actor_type: ^(String) -> (String | Symbol), ?mailbox: Mailbox) -> MessageReference
13+
# @rbs (untyped envelope, ?resolve_actor_type: ^(String) -> (String | Symbol), ?mailbox: Mailbox, ?actor_loader: ^() -> bool) -> MessageReference
14+
def self.receive: (untyped envelope, ?resolve_actor_type: ^(String) -> (String | Symbol), ?mailbox: Mailbox, ?actor_loader: ^() -> bool) -> MessageReference
1515

1616
# @rbs (effect_name: String, arguments: Hash[String, untyped], context: EffectContext, deliver: Proc) -> nil
1717
def self.deliver_through: (effect_name: String, arguments: Hash[String, untyped], context: EffectContext, deliver: Proc) -> nil
1818

19+
# @rbs (String, ^() -> bool) -> Class
20+
private def self.fetch_actor_class: (String, ^() -> bool) -> Class
21+
22+
# @rbs () -> bool
23+
private def self.load_application_actors: () -> bool
24+
1925
# @rbs (Hash[String, untyped], effect_id: String, actor_type: String, actor_id: String) -> Hash[String, untyped]
2026
private def self.staged_envelope: (Hash[String, untyped], effect_id: String, actor_type: String, actor_id: String) -> Hash[String, untyped]
2127

test/integration/load_contract_test.rb

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ class LoadContractTest < ActiveSupport::TestCase
1313
# else that stops being loaded is a role waiting to fail in production, so
1414
# this list is the place to argue that a role never reaches it.
1515
DEFERRED = {
16-
"application_actor_loader" => "used only by the CLI",
1716
"caller_process" => "the caller path, required by SolidObjects.caller_process",
1817
"cli" => "loaded by exe/solid_objects, and pulls in thor",
1918
"client" => "the caller path, required by SolidObjects.client",

test/integration/transmission_test.rb

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,23 @@ def valid_envelope(overrides = {})
8686
assert_equal "transmit-counters", SolidObjects::Message.sole.actor_type
8787
end
8888

89+
test "loads application actors on a registry miss before failing" do
90+
loaded = false
91+
loader = -> do
92+
loaded = true
93+
SolidObjects.register_actor("lazy-counters", CounterActor)
94+
true
95+
end
96+
97+
SolidObjects::Transmission.receive(
98+
valid_envelope("actorType" => "lazy-counters"),
99+
actor_loader: loader
100+
)
101+
102+
assert loaded
103+
assert_equal "lazy-counters", SolidObjects::Message.sole.actor_type
104+
end
105+
89106
test "rejects an unknown actor type" do
90107
assert_raises(SolidObjects::UnknownActorType) do
91108
SolidObjects::Transmission.receive(valid_envelope("actorType" => "missing"))

0 commit comments

Comments
 (0)