Skip to content

Commit 2b74678

Browse files
committed
feat: add synchronous actor RPC
Make direct actor methods caller-assisted synchronous invocations while preserving durable mailbox ordering, leases, and fencing. Add explicit async delivery, terminal domain rejection, unique activation tokens, and the early Action View engine boot fix. Update the portable schema, tests, signatures, benchmarks, and release documentation for 0.2.0.
1 parent 87fd2ac commit 2b74678

71 files changed

Lines changed: 1290 additions & 400 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎CHANGELOG.md‎

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

3+
## 0.2.0 - 2026-08-06
4+
5+
- Make direct actor methods synchronous Durable Object-style RPC.
6+
- Add explicit `sync` and `async` invocation modes.
7+
- Let synchronous callers assist execution through the ordered mailbox using
8+
the same activation leases and fencing checks as workers.
9+
- Add terminal domain rejections that roll back actor state without retrying or
10+
creating dead letters.
11+
- Give each activation a unique token so concurrent callers in one process
12+
cannot share lease ownership.
13+
- Fix Action View helper loading when Action View initializes before engine
14+
autoload paths.
15+
316
## 0.1.0 - 2026-08-06
417

518
- Introduce the Rails engine, actor API, and `solid_objects` executable.

‎Gemfile.lock‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: .
33
specs:
4-
solid_objects (0.1.0)
4+
solid_objects (0.2.0)
55
actioncable (>= 8.0)
66
actionpack (>= 8.0)
77
actionview (>= 8.0)
@@ -373,7 +373,7 @@ CHECKSUMS
373373
rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d
374374
ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
375375
securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
376-
solid_objects (0.1.0)
376+
solid_objects (0.2.0)
377377
sqlite3 (2.9.5-aarch64-linux-gnu) sha256=78075b6337d3d182c6d2b4691049ed45cd220826160c9ea18946bf6a1de200dc
378378
sqlite3 (2.9.5-aarch64-linux-musl) sha256=18c801185deb4adc01ddb281e8f672a39e3d1729979ca91e39439cd3eac0402d
379379
sqlite3 (2.9.5-arm-linux-gnu) sha256=1bdfca0c7d63998c60b0f4a8e3c8df2d33800ccc4abd2d612eddbbbc92a4c48b

‎README.md‎

Lines changed: 67 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ This is a port of the programming model, not Cloudflare's edge runtime or
3434
platform. Read the conceptual overview at [solidobjects.dev](https://solidobjects.dev/)
3535
and the exact Rails guarantees in [Correctness and delivery semantics](docs/correctness.md).
3636

37-
Version 0.1 is an early release. Its correctness core is implemented and tested,
37+
Version 0.2 is an early release. Its correctness core is implemented and tested,
3838
but the project does not yet claim production readiness. See
3939
[Status](#status) and the [roadmap](docs/roadmap.md).
4040

@@ -45,7 +45,7 @@ but the project does not yet claim production readiness. See
4545
- [Installation](#installation)
4646
- [Defining an actor](#defining-an-actor)
4747
- [Actor identity](#actor-identity)
48-
- [Messages and queries](#messages-and-queries)
48+
- [Invoking an object](#invoking-an-object)
4949
- [Effects](#effects)
5050
- [Reminders](#reminders)
5151
- [Destroying an object](#destroying-an-object)
@@ -171,7 +171,7 @@ refresh from current actor state.
171171
`cart.component(:summary)` supports initial rendering of
172172
`actors/shopping_cart/_summary`. Durable live component replacement and
173173
Turbo append actions are roadmap work; observable replacement is the live path
174-
implemented in 0.1.
174+
implemented in 0.2.
175175

176176
Reactive views require `turbo-rails` and a working Action Cable adapter in the
177177
host application. They are optional; the actor runtime itself does not depend
@@ -206,7 +206,8 @@ end
206206

207207
Knowledge of an actor ID or signed stream token is never authorization.
208208

209-
Start the runtime:
209+
Start the runtime for asynchronous messages, effects, reminders, and
210+
broadcasts:
210211

211212
```bash
212213
bundle exec solid_objects start
@@ -251,18 +252,20 @@ declared on the actor are durable message handlers. They can use `items`,
251252
`self.checkout_status = "pending"`, or the lower-level `state` object. Declare
252253
helper methods as private or protected so they are not exposed as messages.
253254

254-
Attributes also become ordered read queries on a reference. Declared messages
255-
become asynchronous methods:
255+
Attributes also become ordered read queries on a reference. Public actor
256+
methods and attribute readers are synchronous caller-assisted invocations:
256257

257258
```ruby
258259
cart = ShoppingCart.ref("alice")
259-
message = cart.add_item(product_id: "shirt-123", quantity: 2)
260+
cart.add_item(product_id: "shirt-123", quantity: 2)
260261
items = cart.items
261262
```
262263

263-
`message` is a `SolidObjects::MessageReference`. `items` is a deeply frozen
264-
JSON snapshot; mutating it cannot bypass the actor mailbox. State changes must
265-
go through public actor message methods.
264+
Use `cart.async(:add_item, product_id: "shirt-123", quantity: 2)` to enqueue
265+
without waiting; that call returns a `SolidObjects::MessageReference`. `items`
266+
is a deeply frozen JSON snapshot, so mutating it cannot bypass the actor
267+
mailbox. State changes must go through public actor methods or explicit
268+
`async`.
266269

267270
State, arguments, results, effects, and reminder arguments accept
268271
JSON-compatible values. Solid Objects never deserializes Ruby `Marshal` data.
@@ -310,7 +313,7 @@ end
310313
Actor types resolve only through the explicit registry. Solid Objects never
311314
constantizes a type supplied by a client.
312315

313-
## Messages and queries
316+
## Invoking an object
314317

315318
As with a Durable Object stub, declared actor operations are available directly
316319
on a reference:
@@ -325,24 +328,27 @@ class Counter < SolidObjects::Actor
325328
end
326329

327330
counter = Counter.ref("global")
328-
message = counter.increment(amount: 5)
331+
value = counter.increment(amount: 5)
329332
value = counter.value
330333
```
331334

332-
Public actor methods are messages and become asynchronous syntax over `tell`,
333-
returning a durable `MessageReference`. Declared queries and attribute readers
334-
are synchronous syntax over `ask`. Unlike Cloudflare RPC, the initial `ask`
335-
implementation waits by polling the durable database row.
336-
The `message(:name) { ... }` DSL remains available for dynamic definitions.
337-
The explicit `tell` and `ask` forms remain available for dynamic operation
338-
names or names that collide with Ruby or reference methods.
335+
Like RPC on a Durable Object stub, a direct call is synchronous from the
336+
caller's perspective. Solid Objects first durably enqueues the invocation, then
337+
executes that actor locally when its fenced activation is available. It returns
338+
the committed, deeply frozen result. Earlier mailbox entries still run first,
339+
and a remote worker may win the activation without changing the result
340+
semantics.
339341

340-
### `tell`
342+
The `message(:name) { ... }` and `query(:name) { ... }` DSLs remain available
343+
for dynamic definitions.
341344

342-
`tell` durably enqueues work and immediately returns a message reference:
345+
### `async`
346+
347+
Use `async` for durable fire-and-forget work. It returns a
348+
`MessageReference` immediately and leaves execution to the worker fleet:
343349

344350
```ruby
345-
message = order.tell(
351+
message = order.async(
346352
:submit,
347353
idempotency_key: "submit-order-123"
348354
)
@@ -351,23 +357,44 @@ message = order.tell(
351357
Use `available_at:` to spread bulk work or delay one message:
352358

353359
```ruby
354-
order.tell(:evaluate, available_at: 10.minutes.from_now)
360+
order.async(:evaluate, available_at: 10.minutes.from_now)
355361
```
356362

357-
### `ask`
363+
### `sync`
358364

359-
`ask` durably enqueues a query or message and waits for its result:
365+
Use explicit `sync` when the operation name is dynamic or collides with a
366+
reference method:
360367

361368
```ruby
362-
status = order.ask(:status, timeout: 5.seconds)
369+
status = order.sync(:status, timeout: 5.seconds)
363370
```
364371

365-
The initial cross-process implementation polls the durable message row. It is
366-
intended for background callers and control paths, not latency-sensitive HTTP
367-
request handling. A timeout does not cancel the actor message.
372+
Direct calls and `sync` use the same caller-assisted execution path. A healthy
373+
actor normally needs no worker round trip, making this path suitable for HTTP
374+
and MCP request/response boundaries when the handler itself fits the
375+
application's latency budget. If another process owns the activation, the
376+
caller waits for the durable result using wake-up hints with bounded database
377+
polling as the fallback. A timeout never cancels the durable invocation.
378+
379+
Actor code cannot use direct calls or `sync` on another actor; synchronous
380+
actor-to-actor waits can deadlock in cycles. Use `async` or `send_to` and a
381+
result message.
382+
383+
### Domain rejection
384+
385+
Reject invalid input without retrying or creating a dead letter:
386+
387+
```ruby
388+
def submit(response:)
389+
reject :validation_failed, "Response is not valid" unless valid?(response)
390+
391+
self.response = response
392+
end
393+
```
368394

369-
Actor code cannot call `ask`; synchronous actor-to-actor waits can deadlock in
370-
cycles. Use `tell` or `send_to` and a result message.
395+
The caller receives `SolidObjects::Rejected` with a stable code, message, and
396+
JSON-compatible details. The rejected message remains durable for audit, actor
397+
state is rolled back, and no later mailbox turn is blocked.
371398

372399
### Redelivery
373400

@@ -445,7 +472,7 @@ enqueue each one.
445472

446473
Self-scheduling actors should also have a low-frequency application reconciler.
447474
It may read `SolidObjects::Instance.states_for`, `.without_pending_work`, and
448-
`.orphaned`, but every repair must go through `tell`. Never bulk-update actor
475+
`.orphaned`, but every repair must go through `async`. Never bulk-update actor
449476
state around the lease and fencing checks.
450477

451478
## Destroying an object
@@ -514,7 +541,7 @@ Important defaults:
514541
| Setting | Default |
515542
| --- | ---: |
516543
| `polling_interval` | 0.1 seconds |
517-
| `ask_polling_interval` | 0.05 seconds |
544+
| `sync_polling_interval` | 0.05 seconds |
518545
| `lease_duration` | 30 seconds |
519546
| `lease_renewal_interval` | 10 seconds |
520547
| `idle_deactivation_timeout` | 30 seconds |
@@ -633,7 +660,7 @@ Solid Objects does not promise:
633660
- global order across actors;
634661
- distributed transactions;
635662
- bounded end-to-end latency;
636-
- cancellation when an `ask` caller times out; or
663+
- cancellation when a synchronous caller times out; or
637664
- that a lease prevents stale Ruby code from continuing to run.
638665

639666
The fencing generation prevents stale code from committing.
@@ -700,15 +727,18 @@ See the [development guide](docs/development.md) and
700727

701728
## Status
702729

703-
Implemented and tested in 0.1:
730+
Implemented and tested in 0.2:
704731

705732
- Rails engine, install generator, migrations, and `solid_objects` executable;
706733
- actor registry, references, JSON state, and state migrations;
734+
- direct synchronous actor RPC, explicit `sync`, and durable `async`;
707735
- durable message history plus ready and claimed membership tables;
708736
- concurrent sequence allocation and actor creation;
709-
- activation leases, renewal, fencing generations, and stale-write rejection;
737+
- activation leases, per-activation tokens, fencing generations, and
738+
stale-write rejection;
710739
- bounded activation passes, idle activation cache, and hot-actor fairness;
711-
- retries, strict poison ordering, dead letters, and retry tooling;
740+
- retries, terminal domain rejection, strict poison ordering, dead letters,
741+
and retry tooling;
712742
- transactional effects and asynchronous actor-to-actor messages;
713743
- one-shot and recurring per-actor reminders;
714744
- authorized actor destruction with fenced stale-write rejection and cascading

‎app/models/solid_objects/claimed_message.rb‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,7 @@ class ClaimedMessage < Record
1010
class_name: "SolidObjects::Process",
1111
foreign_key: :process_id,
1212
optional: true
13+
14+
validates :activation_token, presence: true, if: :process_id?
1315
end
1416
end

‎app/models/solid_objects/message.rb‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ class Message < Record
2020

2121
before_validation :supply_defaults
2222

23-
validates :message_kind, inclusion: { in: %w[tell ask internal] }
23+
validates :message_kind, inclusion: { in: %w[async sync internal] }
2424

2525
# @rbs () -> bool
2626
def ready?
@@ -37,6 +37,11 @@ def completed?
3737
completed_at.present?
3838
end
3939

40+
# @rbs () -> bool
41+
def rejected?
42+
rejected_at.present?
43+
end
44+
4045
# @rbs () -> bool
4146
def dead?
4247
dead_letter.present?

‎benchmark/support.rb‎

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -61,13 +61,13 @@ def measure(name)
6161
def enqueue
6262
reference = CounterActor.ref("enqueue")
6363
measure("enqueue #{count} messages") do
64-
count.times { reference.tell(:increment) }
64+
count.times { reference.async(:increment) }
6565
end
6666
end
6767

6868
# @rbs () -> void
6969
def claim
70-
count.times { |index| CounterActor.ref("claim-#{index}").tell(:increment) }
70+
count.times { |index| CounterActor.ref("claim-#{index}").async(:increment) }
7171
process_registry = SolidObjects::ProcessRegistry.new
7272
owner_id = process_registry.register.id
7373
activation_manager = SolidObjects::ActivationManager.new(owner_id:)
@@ -100,7 +100,7 @@ def processing
100100

101101
# @rbs () -> void
102102
def cold_actors
103-
count.times { |index| CounterActor.ref("cold-#{index}").tell(:increment) }
103+
count.times { |index| CounterActor.ref("cold-#{index}").async(:increment) }
104104
worker = SolidObjects::Worker.new
105105
measure("process #{count} cold actors") { drain(worker) }
106106
ensure
@@ -110,7 +110,7 @@ def cold_actors
110110
# @rbs () -> void
111111
def hot_actor
112112
reference = CounterActor.ref("hot")
113-
count.times { reference.tell(:increment) }
113+
count.times { reference.async(:increment) }
114114
worker = SolidObjects::Worker.new
115115
measure("process #{count} messages for one hot actor") { drain(worker) }
116116
ensure
@@ -130,30 +130,25 @@ def concurrent_actors
130130
end
131131

132132
# @rbs () -> void
133-
def ask_latency
134-
worker = SolidObjects::Worker.new
135-
worker_thread = Thread.new { worker.run }
133+
def sync_latency
136134
samples = []
137135

138136
count.times do |index|
139137
started_at = monotonic_now
140-
CounterActor.ref("ask-#{index}").ask(:count, timeout: 5)
138+
CounterActor.ref("sync-#{index}").sync(:count, timeout: 5)
141139
samples << monotonic_now - started_at
142140
end
143141

144142
sorted = samples.sort
145-
puts "ask #{count} calls: p50=#{milliseconds(percentile(sorted, 0.50))}ms " \
143+
puts "sync #{count} calls: p50=#{milliseconds(percentile(sorted, 0.50))}ms " \
146144
"p95=#{milliseconds(percentile(sorted, 0.95))}ms " \
147145
"p99=#{milliseconds(percentile(sorted, 0.99))}ms"
148-
ensure
149-
worker&.request_shutdown
150-
worker_thread&.join
151146
end
152147

153148
# @rbs () -> void
154149
def activation_cache
155150
reference = CounterActor.ref("cache")
156-
count.times { reference.tell(:increment) }
151+
count.times { reference.async(:increment) }
157152
activations = 0
158153
subscriber = ActiveSupport::Notifications.subscribe("solid_objects.activation.started") do
159154
activations += 1
@@ -170,7 +165,7 @@ def activation_cache
170165

171166
# @rbs () -> void
172167
def query_count
173-
CounterActor.ref("queries").tell(:increment)
168+
CounterActor.ref("queries").async(:increment)
174169
worker = SolidObjects::Worker.new
175170
queries = 0
176171
subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |event|
@@ -232,7 +227,7 @@ def load_models
232227
def enqueue_round_robin
233228
actor_count = [ concurrency * 10, count ].min
234229
references = Array.new(actor_count) { |index| CounterActor.ref("actor-#{index}") }
235-
count.times { |index| references[index % actor_count].tell(:increment) }
230+
count.times { |index| references[index % actor_count].async(:increment) }
236231
end
237232

238233
# @rbs (SolidObjects::Worker) -> Integer
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22

33
require_relative "support"
44

5-
SolidObjectsBenchmark.ask_latency
5+
SolidObjectsBenchmark.sync_latency

0 commit comments

Comments
 (0)