Skip to content

Commit 0e912ce

Browse files
committed
Add fluent actor dispatch
1 parent 13ff644 commit 0e912ce

37 files changed

Lines changed: 707 additions & 206 deletions

CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,23 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
- Replace positional actor dispatch with fluent operation selection. Direct
6+
committed calls remain `account.disable(...)`; configured committed calls
7+
use `account.sync(timeout: ...).status`; asynchronous calls use
8+
`account.async(...).disable(...)`; actor outbox delivery uses
9+
`send_to(account, ...).disable(...)`; and reminders use
10+
`schedule(at: ...).evaluate(...)`. Delivery and reminder options are now
11+
unambiguously separate from actor message arguments. The former positional
12+
`async`, `sync`, `send_to`, and `schedule` forms are removed.
13+
- Validate fluent operations before enqueueing or staging them. Direct and
14+
configured synchronous calls accept messages and queries, while `async`,
15+
`send_to`, and `schedule` accept public actor messages only.
16+
- Keep SQLite reconnect failures inside a synchronous lock deadline. Active
17+
Record can reconnect after a lock error while the lock is still held, and
18+
configuring WAL then raises `SQLite3::CantOpenException`; it now retries
19+
within the original deadline and surfaces the documented sync timeout.
20+
321
## 0.11.0 - 2026-08-11
422

523
- Add `SolidObjects.configuration.register_component`. An extension gem can now

README.md

Lines changed: 39 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ current_count = counter.value
2727
current_snapshot = counter.snapshot.value
2828

2929
# Durable fire-and-forget delivery. A worker processes it later.
30-
message = counter.async(:increment, amount: 5)
30+
message = counter.async.increment(amount: 5)
3131
```
3232

3333
`Counter / global` is a logical identity. Like a Durable Object named with
@@ -41,10 +41,10 @@ The invocation model is the first adoption decision:
4141
| Call | Returns | Worker fleet required? |
4242
| --- | --- | --- |
4343
| `counter.increment(amount: 5)` | Committed handler result | No |
44-
| `counter.sync(:increment, amount: 5)` | Committed handler result | No |
44+
| `counter.sync(timeout: 5.seconds).increment(amount: 5)` | Committed handler result | No |
4545
| `counter.value` | Ordered, committed query result | No |
4646
| `counter.snapshot.value` | Current committed state without a mailbox message | No |
47-
| `counter.async(:increment, amount: 5)` | `MessageReference` immediately | Yes |
47+
| `counter.async.increment(amount: 5)` | `MessageReference` immediately | Yes |
4848

4949
Direct methods and `sync` durably enqueue the call, then the Rails caller helps
5050
execute the actor through the same mailbox, lease, and fencing path as a
@@ -155,7 +155,7 @@ durable reminders owned by one logical identity:
155155

156156
```ruby
157157
def schedule_expiration
158-
schedule :expire, at: 30.minutes.from_now, arguments: {}
158+
schedule(at: 30.minutes.from_now).expire
159159
end
160160
```
161161

@@ -536,7 +536,7 @@ cart.add_item(product_id: "shirt-123", quantity: 2)
536536
items = cart.items
537537
```
538538

539-
Use `cart.async(:add_item, product_id: "shirt-123", quantity: 2)` to enqueue
539+
Use `cart.async.add_item(product_id: "shirt-123", quantity: 2)` to enqueue
540540
without waiting; that call returns a `SolidObjects::MessageReference`. `items`
541541
is a deeply frozen JSON snapshot, so mutating it cannot bypass the actor
542542
mailbox. State changes must go through public actor methods or explicit
@@ -638,26 +638,34 @@ Use `async` for durable fire-and-forget work. It returns a
638638

639639
```ruby
640640
message = order.async(
641-
:submit,
642-
idempotency_key: "submit-order-123"
643-
)
641+
idempotency_key: "submit-order-123",
642+
authorization_context: Current.user
643+
).submit
644644
```
645645

646646
Use `available_at:` to spread bulk work or delay one message:
647647

648648
```ruby
649-
order.async(:evaluate, available_at: 10.minutes.from_now)
649+
order.async(available_at: 10.minutes.from_now).evaluate
650650
```
651651

652652
### `sync`
653653

654-
Use explicit `sync` when the operation name is dynamic or collides with a
655-
reference method:
654+
Use explicit `sync` when the invocation needs a timeout, idempotency key, or
655+
authorization context different from the defaults:
656656

657657
```ruby
658-
status = order.sync(:status, timeout: 5.seconds)
658+
status = order.sync(
659+
timeout: 5.seconds,
660+
authorization_context: Current.user
661+
).status
659662
```
660663

664+
Delivery configuration belongs on `async(...)` or `sync(...)` before the
665+
operation. Keywords on the final method call are always actor message
666+
arguments, so `order.sync(timeout: 5.seconds).record(timeout: "payload")`
667+
keeps the invocation timeout separate from the payload value.
668+
661669
Direct calls and `sync` use the same caller-assisted execution path. A healthy
662670
actor normally needs no worker round trip, making this path suitable for HTTP
663671
and MCP request/response boundaries when the handler itself fits the
@@ -677,7 +685,7 @@ recover its eventual result through the durable message identity:
677685

678686
```ruby
679687
begin
680-
order.submit(timeout: 250.milliseconds)
688+
order.sync(timeout: 250.milliseconds).submit
681689
rescue SolidObjects::SyncTimeout => error
682690
result = error.message_reference.wait(
683691
timeout: 5.seconds,
@@ -700,6 +708,17 @@ Actor code cannot use direct calls or `sync` on another actor; synchronous
700708
actor-to-actor waits can deadlock in cycles. Use `async` or `send_to` and a
701709
result message.
702710

711+
```ruby
712+
send_to(
713+
audit_log,
714+
available_at: 5.minutes.from_now,
715+
idempotency_key: event_id
716+
).record(event_id:, event_name: "account_disabled")
717+
```
718+
719+
Actor-to-actor delivery is staged with the current turn, returns `nil`, and is
720+
discarded if that turn does not commit. It accepts messages, not queries.
721+
703722
### Domain rejection
704723

705724
Reject invalid input without retrying or creating a dead letter:
@@ -831,7 +850,11 @@ API. One-shot and recurring alarms are actor-owned database records:
831850

832851
```ruby
833852
def schedule_evaluation
834-
schedule :evaluate, at: 1.hour.from_now, every: 1.hour, missed: :latest
853+
schedule(
854+
at: 1.hour.from_now,
855+
every: 1.hour,
856+
missed: :latest
857+
).evaluate(account_id:)
835858
end
836859
```
837860

@@ -852,7 +875,7 @@ once. It also means this is a data-loss bug:
852875
# Wrong. Every entry overwrites the previous entry's alarm.
853876
def add(entry:)
854877
self.entries = entries + [ entry ]
855-
schedule :deliver, at: entry.fetch("wait_until"), arguments: {}
878+
schedule(at: entry.fetch("wait_until")).deliver
856879
end
857880
```
858881

@@ -882,7 +905,7 @@ def arm_next
882905
earliest = entries.first
883906
return unless earliest
884907

885-
schedule :deliver, at: Time.at(earliest.fetch("wait_until")), arguments: {}
908+
schedule(at: Time.at(earliest.fetch("wait_until"))).deliver
886909
end
887910
```
888911

benchmark/support.rb

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -98,13 +98,13 @@ def measure(name)
9898
def enqueue
9999
reference = CounterActor.ref("enqueue")
100100
measure("enqueue #{count} messages") do
101-
count.times { reference.async(:increment) }
101+
count.times { reference.async.increment }
102102
end
103103
end
104104

105105
# @rbs () -> void
106106
def claim
107-
count.times { |index| CounterActor.ref("claim-#{index}").async(:increment) }
107+
count.times { |index| CounterActor.ref("claim-#{index}").async.increment }
108108
process_registry = SolidObjects::ProcessRegistry.new
109109
owner_id = process_registry.register.id
110110
activation_manager = SolidObjects::ActivationManager.new(owner_id:)
@@ -137,7 +137,7 @@ def processing
137137

138138
# @rbs () -> void
139139
def cold_actors
140-
count.times { |index| CounterActor.ref("cold-#{index}").async(:increment) }
140+
count.times { |index| CounterActor.ref("cold-#{index}").async.increment }
141141
worker = SolidObjects::Worker.new
142142
measure("process #{count} cold actors") { drain(worker) }
143143
ensure
@@ -147,7 +147,7 @@ def cold_actors
147147
# @rbs () -> void
148148
def hot_actor
149149
reference = CounterActor.ref("hot")
150-
count.times { reference.async(:increment) }
150+
count.times { reference.async.increment }
151151
worker = SolidObjects::Worker.new
152152
measure("process #{count} messages for one hot actor") { drain(worker) }
153153
ensure
@@ -172,7 +172,7 @@ def sync_latency
172172

173173
count.times do |index|
174174
started_at = monotonic_now
175-
CounterActor.ref("sync-#{index}").sync(:count, timeout: 5)
175+
CounterActor.ref("sync-#{index}").sync(timeout: 5).count
176176
samples << monotonic_now - started_at
177177
end
178178

@@ -211,7 +211,7 @@ def adoption_latency
211211
# @rbs () -> void
212212
def activation_cache
213213
reference = CounterActor.ref("cache")
214-
count.times { reference.async(:increment) }
214+
count.times { reference.async.increment }
215215
activations = 0
216216
subscriber = ActiveSupport::Notifications.subscribe("solid_objects.activation.started") do
217217
activations += 1
@@ -303,7 +303,7 @@ def query_count
303303
# Runs the turn on the measuring thread, so nothing else can contribute.
304304
# @rbs () -> Integer
305305
def message_turn_query_count
306-
CounterActor.ref("queries").async(:increment)
306+
CounterActor.ref("queries").async.increment
307307
worker = SolidObjects::Worker.new
308308
count_queries { worker.run_once }
309309
ensure
@@ -322,8 +322,8 @@ def synchronous_caller_query_count
322322
reference = CounterActor.ref("sync-queries")
323323
worker = SolidObjects::Worker.new
324324
runner = Thread.new { worker.run }
325-
reference.sync(:increment)
326-
count_queries { reference.sync(:increment) }
325+
reference.sync.increment
326+
count_queries { reference.sync.increment }
327327
ensure
328328
worker&.request_shutdown
329329
runner&.join(5)
@@ -428,7 +428,7 @@ def load_models
428428
def enqueue_round_robin
429429
actor_count = [ concurrency * 10, count ].min
430430
references = Array.new(actor_count) { |index| CounterActor.ref("actor-#{index}") }
431-
count.times { |index| references[index % actor_count].async(:increment) }
431+
count.times { |index| references[index % actor_count].async.increment }
432432
end
433433

434434
# @rbs (SolidObjects::Worker) -> Integer

docs/architecture.md

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,9 @@ The registry maps a stable persisted actor type string to a Ruby actor class. Re
6363
A reference contains actor type and normalized actor ID. It is cheap,
6464
serializable as data, and does not imply an active Ruby object. Declared
6565
message, query, and attribute methods use synchronous caller-assisted
66-
invocation. `sync` provides the same behavior for a dynamic operation name,
67-
while `async` only durably enqueues a message and returns its reference.
66+
invocation. `sync(...)` configures a caller-assisted invocation before its
67+
operation is selected, while `async(...)` configures durable enqueue and
68+
returns its message reference after the operation is selected.
6869
`destroy` is a reserved synchronous reference operation. Every path authorizes
6970
through the client.
7071

@@ -155,8 +156,9 @@ Public instance methods declared on the actor are messages. Declare helpers as
155156
private or protected. Messages and queries are exposed as methods on a
156157
reference through the same synchronous caller-assisted path. Returned state
157158
snapshots are deeply frozen. Use `async` for durable fire-and-forget delivery
158-
and `sync` for dynamic operation names. The explicit `message` DSL remains
159-
available for dynamic definitions.
159+
and configured `sync` when a committed call needs a non-default timeout,
160+
idempotency key, or authorization context. The explicit `message` DSL remains
161+
available for declarations defined dynamically.
160162

161163
`reference.snapshot` is the explicit unordered read path. It invokes query
162164
authorization, reads the most recently committed instance state without
@@ -426,7 +428,7 @@ An effect handler receives JSON arguments plus an effect context. Outcome messag
426428
Actor code sends to another actor through a staged outbox:
427429

428430
```ruby
429-
send_to InventoryActor.ref("sku-123"), :reserve, order_id: id, quantity: 2
431+
send_to(InventoryActor.ref("sku-123")).reserve(order_id: id, quantity: 2)
430432
```
431433

432434
The source actor commit never waits for the target. The outbox worker allocates the target's sequence after source commit. There is no global order across actors.
@@ -436,7 +438,7 @@ The source actor commit never waits for the target. The outbox worker allocates
436438
A reminder record contains actor identity, a reminder name, target message, JSON arguments, next run time, optional interval, status, and occurrence counter.
437439

438440
```ruby
439-
schedule :expire, at: 30.minutes.from_now, arguments: {}
441+
schedule(at: 30.minutes.from_now).expire
440442
```
441443

442444
Reminders are keyed by `(actor, reminder name)`, enforced by a unique index on `(instance_id, name)`. `schedule` is therefore an upsert: scheduling a name that is already armed moves that alarm instead of adding another, which is what makes re-arming safe from a handler that may run more than once. An actor needing several pending items should arm one alarm for the earliest and drain everything due when it fires, rather than one alarm per item; the [reminders guide](../README.md#a-reminder-is-one-named-alarm-per-actor) shows that pattern. A move that changes `next_run_at` emits `solid_objects.reminder.replaced`, because the replacement is otherwise indistinguishable from a first schedule.
@@ -467,7 +469,7 @@ The expected drift categories are actors with a lost alarm, missing actors for l
467469

468470
Bulk repair updates to `solid_objects_instances` are forbidden. They bypass activation ownership and fencing and can overwrite a concurrently committed actor state. Direct reads are observational; writes go through actor messages.
469471

470-
Large repairs use `async(..., available_at:)` to spread work over an application-defined dispatch window. The durable message records the requested availability and ready membership drives the hot polling query. This prevents reconciliation from flooding mailboxes and starving normal traffic.
472+
Large repairs use `async(available_at: ...).repair(...)` to spread work over an application-defined dispatch window. The durable message records the requested availability and ready membership drives the hot polling query. This prevents reconciliation from flooding mailboxes and starving normal traffic.
471473

472474
## Realtime integration
473475

docs/development.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ Use `drain_solid_objects` to process actor, reminder, effect, callback, and
5656
broadcast work to a deterministic fixed point without arbitrary sleeps:
5757

5858
```ruby
59-
message = Counter.ref("test").async(:increment)
59+
message = Counter.ref("test").async.increment
6060

6161
assert_equal 1, drain_solid_objects
6262
assert_equal "completed", message.status

docs/migrating-existing-state.md

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,11 +71,9 @@ Give every bootstrap call an idempotency key derived from the legacy record:
7171

7272
```ruby
7373
session.actor.async(
74-
:bootstrap,
75-
answers: legacy.answers,
7674
idempotency_key: "legacy-assessment:#{legacy.id}",
7775
available_at: jittered_time
78-
)
76+
).bootstrap(answers: legacy.answers)
7977
```
8078

8179
Spread large backfills over a dispatch window and monitor mailbox age,

docs/roadmap.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
- Rails engine, install generator, migration, and CLI
66
- Explicit actor registry, references, JSON state, and state migrations
7-
- Direct synchronous RPC, explicit `sync`, and durable `async`
7+
- Fluent direct synchronous RPC, configured `sync`, and durable `async`
88
- Durable message history plus ready/claimed membership tables
99
- Concurrent sequence allocation and actor creation
1010
- Activation leases, renewal, unique activation tokens, generations, and

lib/solid_objects.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
require "solid_objects/actor_registry"
2323
require "solid_objects/state"
2424
require "solid_objects/actor_definition"
25+
require "solid_objects/operation_dispatcher"
2526
require "solid_objects/application_write_guard"
2627
require "solid_objects/actor"
2728
require "solid_objects/reference"

lib/solid_objects/actor.rb

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -196,8 +196,8 @@ def commit_action(name, **arguments)
196196
nil
197197
end
198198

199-
# @rbs (Symbol | String, at: Time, ?every: Numeric?, ?missed: Symbol | String, arguments: Hash[Symbol | String, untyped]) -> nil
200-
def schedule(name, at:, every: nil, missed: :latest, arguments: {})
199+
# @rbs (at: Time, ?every: Numeric?, ?missed: Symbol | String) -> OperationDispatcher
200+
def schedule(at:, every: nil, missed: :latest)
201201
interval_seconds = every&.to_f
202202
if interval_seconds && !interval_seconds.positive?
203203
raise ArgumentError, "reminder interval must be positive"
@@ -207,22 +207,33 @@ def schedule(name, at:, every: nil, missed: :latest, arguments: {})
207207
raise ArgumentError, "missed reminder policy must be all or latest"
208208
end
209209

210-
ReminderIntent.new(
211-
name: name.to_s,
212-
at:,
213-
arguments: Serialization.dump(arguments),
214-
interval_seconds:,
215-
missed_policy:
216-
).tap do |intent|
217-
reminder_intents << intent
210+
OperationDispatcher.new(
211+
actor_type: self.class.actor_type,
212+
handlers: self.class.definition.messages
213+
) do |message_name, arguments|
214+
ReminderIntent.new(
215+
name: message_name.to_s,
216+
at:,
217+
arguments: Serialization.dump(arguments),
218+
interval_seconds:,
219+
missed_policy:
220+
).tap do |intent|
221+
reminder_intents << intent
222+
end
223+
nil
218224
end
219-
nil
220225
end
221226

222-
# @rbs (Reference, Symbol | String, ?available_at: Time?, ?idempotency_key: String?, **untyped) -> nil
223-
def send_to(reference, message_name, available_at: nil, idempotency_key: nil, **arguments)
224-
stage_outbound_message(reference, message_name, arguments, available_at:, idempotency_key:)
225-
nil
227+
# @rbs (Reference, ?available_at: Time?, ?idempotency_key: String?) -> OperationDispatcher
228+
def send_to(reference, available_at: nil, idempotency_key: nil)
229+
actor_class = SolidObjects.registry.fetch(reference.actor_type)
230+
OperationDispatcher.new(
231+
actor_type: reference.actor_type,
232+
handlers: actor_class.definition.messages
233+
) do |message_name, arguments|
234+
stage_outbound_message(reference, message_name, arguments, available_at:, idempotency_key:)
235+
nil
236+
end
226237
end
227238

228239
# @rbs (Symbol | String, Hash[String, untyped]) -> untyped

0 commit comments

Comments
 (0)