solid_objects 0.1.0 → 0.2.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +13 -0
- data/README.md +67 -37
- data/app/models/solid_objects/claimed_message.rb +2 -0
- data/app/models/solid_objects/message.rb +6 -1
- data/benchmark/support.rb +10 -15
- data/benchmark/{ask_latency.rb → sync_latency.rb} +1 -1
- data/db/migrate/20260805000000_create_solid_objects_tables.rb +15 -3
- data/docs/adr/0006-at-least-once-delivery.md +1 -1
- data/docs/adr/0008-actor-communication.md +4 -1
- data/docs/adr/0011-wake-up-strategy.md +13 -4
- data/docs/architecture.md +51 -20
- data/docs/benchmarks.md +9 -9
- data/docs/correctness.md +28 -9
- data/docs/database-schema.md +15 -7
- data/docs/development.md +3 -3
- data/docs/implementation-plan.md +22 -16
- data/docs/operations.md +3 -3
- data/docs/research/solid_queue.md +2 -1
- data/docs/roadmap.md +5 -2
- data/docs/security.md +4 -2
- data/lib/solid_objects/activation.rb +35 -19
- data/lib/solid_objects/activation_manager.rb +22 -6
- data/lib/solid_objects/actor.rb +16 -3
- data/lib/solid_objects/caller_process.rb +57 -0
- data/lib/solid_objects/client.rb +6 -37
- data/lib/solid_objects/configuration.rb +4 -4
- data/lib/solid_objects/engine.rb +1 -0
- data/lib/solid_objects/errors.rb +17 -1
- data/lib/solid_objects/executor.rb +42 -2
- data/lib/solid_objects/lease.rb +22 -10
- data/lib/solid_objects/message_reference.rb +1 -0
- data/lib/solid_objects/process_registry.rb +5 -1
- data/lib/solid_objects/reference.rb +8 -8
- data/lib/solid_objects/synchronous_invocation.rb +93 -0
- data/lib/solid_objects/version.rb +1 -1
- data/lib/solid_objects.rb +7 -0
- data/sig/generated/lib/solid_objects/activation.rbs +6 -0
- data/sig/generated/lib/solid_objects/activation_manager.rbs +6 -0
- data/sig/generated/lib/solid_objects/actor.rbs +9 -6
- data/sig/generated/lib/solid_objects/caller_process.rbs +32 -0
- data/sig/generated/lib/solid_objects/client.rbs +2 -8
- data/sig/generated/lib/solid_objects/configuration.rbs +2 -2
- data/sig/generated/lib/solid_objects/errors.rbs +18 -1
- data/sig/generated/lib/solid_objects/executor.rbs +3 -0
- data/sig/generated/lib/solid_objects/lease.rbs +14 -10
- data/sig/generated/lib/solid_objects/reference.rbs +3 -3
- data/sig/generated/lib/solid_objects/synchronous_invocation.rbs +28 -0
- data/sig/generated/lib/solid_objects.rbs +3 -0
- data/sig/generated/models/solid_objects/message.rbs +3 -0
- metadata +8 -8
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4d95ae7c1b6791ce00951e73eaf535a7b0b357c6708112509544de82eb59314d
|
|
4
|
+
data.tar.gz: a6a9ef715c217e38959438d1ab549447e0814c621746967dece190ae46b3108b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d604e195d678ae5f2ae24932fd0431a7076d0f8258b9e064bbc463042b911512edc7a6b613673b6731256d0b0603311708beb2158d79e421dc85b5febfb37f4d
|
|
7
|
+
data.tar.gz: cd817466462c8d0c843fa9766df1f2190ed3b477466661e32a47859f7fc63349327af7cb077d75ab5a43b621bb3ee98e321363b007ff5ad19e311123d871e040
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
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
|
+
|
|
3
16
|
## 0.1.0 - 2026-08-06
|
|
4
17
|
|
|
5
18
|
- Introduce the Rails engine, actor API, and `solid_objects` executable.
|
data/README.md
CHANGED
|
@@ -34,7 +34,7 @@ This is a port of the programming model, not Cloudflare's edge runtime or
|
|
|
34
34
|
platform. Read the conceptual overview at [solidobjects.dev](https://solidobjects.dev/)
|
|
35
35
|
and the exact Rails guarantees in [Correctness and delivery semantics](docs/correctness.md).
|
|
36
36
|
|
|
37
|
-
Version 0.
|
|
37
|
+
Version 0.2 is an early release. Its correctness core is implemented and tested,
|
|
38
38
|
but the project does not yet claim production readiness. See
|
|
39
39
|
[Status](#status) and the [roadmap](docs/roadmap.md).
|
|
40
40
|
|
|
@@ -45,7 +45,7 @@ but the project does not yet claim production readiness. See
|
|
|
45
45
|
- [Installation](#installation)
|
|
46
46
|
- [Defining an actor](#defining-an-actor)
|
|
47
47
|
- [Actor identity](#actor-identity)
|
|
48
|
-
- [
|
|
48
|
+
- [Invoking an object](#invoking-an-object)
|
|
49
49
|
- [Effects](#effects)
|
|
50
50
|
- [Reminders](#reminders)
|
|
51
51
|
- [Destroying an object](#destroying-an-object)
|
|
@@ -171,7 +171,7 @@ refresh from current actor state.
|
|
|
171
171
|
`cart.component(:summary)` supports initial rendering of
|
|
172
172
|
`actors/shopping_cart/_summary`. Durable live component replacement and
|
|
173
173
|
Turbo append actions are roadmap work; observable replacement is the live path
|
|
174
|
-
implemented in 0.
|
|
174
|
+
implemented in 0.2.
|
|
175
175
|
|
|
176
176
|
Reactive views require `turbo-rails` and a working Action Cable adapter in the
|
|
177
177
|
host application. They are optional; the actor runtime itself does not depend
|
|
@@ -206,7 +206,8 @@ end
|
|
|
206
206
|
|
|
207
207
|
Knowledge of an actor ID or signed stream token is never authorization.
|
|
208
208
|
|
|
209
|
-
Start the runtime
|
|
209
|
+
Start the runtime for asynchronous messages, effects, reminders, and
|
|
210
|
+
broadcasts:
|
|
210
211
|
|
|
211
212
|
```bash
|
|
212
213
|
bundle exec solid_objects start
|
|
@@ -251,18 +252,20 @@ declared on the actor are durable message handlers. They can use `items`,
|
|
|
251
252
|
`self.checkout_status = "pending"`, or the lower-level `state` object. Declare
|
|
252
253
|
helper methods as private or protected so they are not exposed as messages.
|
|
253
254
|
|
|
254
|
-
Attributes also become ordered read queries on a reference.
|
|
255
|
-
|
|
255
|
+
Attributes also become ordered read queries on a reference. Public actor
|
|
256
|
+
methods and attribute readers are synchronous caller-assisted invocations:
|
|
256
257
|
|
|
257
258
|
```ruby
|
|
258
259
|
cart = ShoppingCart.ref("alice")
|
|
259
|
-
|
|
260
|
+
cart.add_item(product_id: "shirt-123", quantity: 2)
|
|
260
261
|
items = cart.items
|
|
261
262
|
```
|
|
262
263
|
|
|
263
|
-
`
|
|
264
|
-
|
|
265
|
-
|
|
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`.
|
|
266
269
|
|
|
267
270
|
State, arguments, results, effects, and reminder arguments accept
|
|
268
271
|
JSON-compatible values. Solid Objects never deserializes Ruby `Marshal` data.
|
|
@@ -310,7 +313,7 @@ end
|
|
|
310
313
|
Actor types resolve only through the explicit registry. Solid Objects never
|
|
311
314
|
constantizes a type supplied by a client.
|
|
312
315
|
|
|
313
|
-
##
|
|
316
|
+
## Invoking an object
|
|
314
317
|
|
|
315
318
|
As with a Durable Object stub, declared actor operations are available directly
|
|
316
319
|
on a reference:
|
|
@@ -325,24 +328,27 @@ class Counter < SolidObjects::Actor
|
|
|
325
328
|
end
|
|
326
329
|
|
|
327
330
|
counter = Counter.ref("global")
|
|
328
|
-
|
|
331
|
+
value = counter.increment(amount: 5)
|
|
329
332
|
value = counter.value
|
|
330
333
|
```
|
|
331
334
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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.
|
|
339
341
|
|
|
340
|
-
|
|
342
|
+
The `message(:name) { ... }` and `query(:name) { ... }` DSLs remain available
|
|
343
|
+
for dynamic definitions.
|
|
341
344
|
|
|
342
|
-
`
|
|
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:
|
|
343
349
|
|
|
344
350
|
```ruby
|
|
345
|
-
message = order.
|
|
351
|
+
message = order.async(
|
|
346
352
|
:submit,
|
|
347
353
|
idempotency_key: "submit-order-123"
|
|
348
354
|
)
|
|
@@ -351,23 +357,44 @@ message = order.tell(
|
|
|
351
357
|
Use `available_at:` to spread bulk work or delay one message:
|
|
352
358
|
|
|
353
359
|
```ruby
|
|
354
|
-
order.
|
|
360
|
+
order.async(:evaluate, available_at: 10.minutes.from_now)
|
|
355
361
|
```
|
|
356
362
|
|
|
357
|
-
### `
|
|
363
|
+
### `sync`
|
|
358
364
|
|
|
359
|
-
`
|
|
365
|
+
Use explicit `sync` when the operation name is dynamic or collides with a
|
|
366
|
+
reference method:
|
|
360
367
|
|
|
361
368
|
```ruby
|
|
362
|
-
status = order.
|
|
369
|
+
status = order.sync(:status, timeout: 5.seconds)
|
|
363
370
|
```
|
|
364
371
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
request
|
|
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
|
+
```
|
|
368
394
|
|
|
369
|
-
|
|
370
|
-
|
|
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.
|
|
371
398
|
|
|
372
399
|
### Redelivery
|
|
373
400
|
|
|
@@ -445,7 +472,7 @@ enqueue each one.
|
|
|
445
472
|
|
|
446
473
|
Self-scheduling actors should also have a low-frequency application reconciler.
|
|
447
474
|
It may read `SolidObjects::Instance.states_for`, `.without_pending_work`, and
|
|
448
|
-
`.orphaned`, but every repair must go through `
|
|
475
|
+
`.orphaned`, but every repair must go through `async`. Never bulk-update actor
|
|
449
476
|
state around the lease and fencing checks.
|
|
450
477
|
|
|
451
478
|
## Destroying an object
|
|
@@ -514,7 +541,7 @@ Important defaults:
|
|
|
514
541
|
| Setting | Default |
|
|
515
542
|
| --- | ---: |
|
|
516
543
|
| `polling_interval` | 0.1 seconds |
|
|
517
|
-
| `
|
|
544
|
+
| `sync_polling_interval` | 0.05 seconds |
|
|
518
545
|
| `lease_duration` | 30 seconds |
|
|
519
546
|
| `lease_renewal_interval` | 10 seconds |
|
|
520
547
|
| `idle_deactivation_timeout` | 30 seconds |
|
|
@@ -633,7 +660,7 @@ Solid Objects does not promise:
|
|
|
633
660
|
- global order across actors;
|
|
634
661
|
- distributed transactions;
|
|
635
662
|
- bounded end-to-end latency;
|
|
636
|
-
- cancellation when
|
|
663
|
+
- cancellation when a synchronous caller times out; or
|
|
637
664
|
- that a lease prevents stale Ruby code from continuing to run.
|
|
638
665
|
|
|
639
666
|
The fencing generation prevents stale code from committing.
|
|
@@ -700,15 +727,18 @@ See the [development guide](docs/development.md) and
|
|
|
700
727
|
|
|
701
728
|
## Status
|
|
702
729
|
|
|
703
|
-
Implemented and tested in 0.
|
|
730
|
+
Implemented and tested in 0.2:
|
|
704
731
|
|
|
705
732
|
- Rails engine, install generator, migrations, and `solid_objects` executable;
|
|
706
733
|
- actor registry, references, JSON state, and state migrations;
|
|
734
|
+
- direct synchronous actor RPC, explicit `sync`, and durable `async`;
|
|
707
735
|
- durable message history plus ready and claimed membership tables;
|
|
708
736
|
- concurrent sequence allocation and actor creation;
|
|
709
|
-
- activation leases,
|
|
737
|
+
- activation leases, per-activation tokens, fencing generations, and
|
|
738
|
+
stale-write rejection;
|
|
710
739
|
- bounded activation passes, idle activation cache, and hot-actor fairness;
|
|
711
|
-
- retries, strict poison ordering, dead letters,
|
|
740
|
+
- retries, terminal domain rejection, strict poison ordering, dead letters,
|
|
741
|
+
and retry tooling;
|
|
712
742
|
- transactional effects and asynchronous actor-to-actor messages;
|
|
713
743
|
- one-shot and recurring per-actor reminders;
|
|
714
744
|
- authorized actor destruction with fenced stale-write rejection and cascading
|
|
@@ -20,7 +20,7 @@ module SolidObjects
|
|
|
20
20
|
|
|
21
21
|
before_validation :supply_defaults
|
|
22
22
|
|
|
23
|
-
validates :message_kind, inclusion: { in: %w[
|
|
23
|
+
validates :message_kind, inclusion: { in: %w[async sync internal] }
|
|
24
24
|
|
|
25
25
|
# @rbs () -> bool
|
|
26
26
|
def ready?
|
|
@@ -37,6 +37,11 @@ module SolidObjects
|
|
|
37
37
|
completed_at.present?
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
+
# @rbs () -> bool
|
|
41
|
+
def rejected?
|
|
42
|
+
rejected_at.present?
|
|
43
|
+
end
|
|
44
|
+
|
|
40
45
|
# @rbs () -> bool
|
|
41
46
|
def dead?
|
|
42
47
|
dead_letter.present?
|
data/benchmark/support.rb
CHANGED
|
@@ -61,13 +61,13 @@ module SolidObjectsBenchmark
|
|
|
61
61
|
def enqueue
|
|
62
62
|
reference = CounterActor.ref("enqueue")
|
|
63
63
|
measure("enqueue #{count} messages") do
|
|
64
|
-
count.times { reference.
|
|
64
|
+
count.times { reference.async(:increment) }
|
|
65
65
|
end
|
|
66
66
|
end
|
|
67
67
|
|
|
68
68
|
# @rbs () -> void
|
|
69
69
|
def claim
|
|
70
|
-
count.times { |index| CounterActor.ref("claim-#{index}").
|
|
70
|
+
count.times { |index| CounterActor.ref("claim-#{index}").async(:increment) }
|
|
71
71
|
process_registry = SolidObjects::ProcessRegistry.new
|
|
72
72
|
owner_id = process_registry.register.id
|
|
73
73
|
activation_manager = SolidObjects::ActivationManager.new(owner_id:)
|
|
@@ -100,7 +100,7 @@ module SolidObjectsBenchmark
|
|
|
100
100
|
|
|
101
101
|
# @rbs () -> void
|
|
102
102
|
def cold_actors
|
|
103
|
-
count.times { |index| CounterActor.ref("cold-#{index}").
|
|
103
|
+
count.times { |index| CounterActor.ref("cold-#{index}").async(:increment) }
|
|
104
104
|
worker = SolidObjects::Worker.new
|
|
105
105
|
measure("process #{count} cold actors") { drain(worker) }
|
|
106
106
|
ensure
|
|
@@ -110,7 +110,7 @@ module SolidObjectsBenchmark
|
|
|
110
110
|
# @rbs () -> void
|
|
111
111
|
def hot_actor
|
|
112
112
|
reference = CounterActor.ref("hot")
|
|
113
|
-
count.times { reference.
|
|
113
|
+
count.times { reference.async(:increment) }
|
|
114
114
|
worker = SolidObjects::Worker.new
|
|
115
115
|
measure("process #{count} messages for one hot actor") { drain(worker) }
|
|
116
116
|
ensure
|
|
@@ -130,30 +130,25 @@ module SolidObjectsBenchmark
|
|
|
130
130
|
end
|
|
131
131
|
|
|
132
132
|
# @rbs () -> void
|
|
133
|
-
def
|
|
134
|
-
worker = SolidObjects::Worker.new
|
|
135
|
-
worker_thread = Thread.new { worker.run }
|
|
133
|
+
def sync_latency
|
|
136
134
|
samples = []
|
|
137
135
|
|
|
138
136
|
count.times do |index|
|
|
139
137
|
started_at = monotonic_now
|
|
140
|
-
CounterActor.ref("
|
|
138
|
+
CounterActor.ref("sync-#{index}").sync(:count, timeout: 5)
|
|
141
139
|
samples << monotonic_now - started_at
|
|
142
140
|
end
|
|
143
141
|
|
|
144
142
|
sorted = samples.sort
|
|
145
|
-
puts "
|
|
143
|
+
puts "sync #{count} calls: p50=#{milliseconds(percentile(sorted, 0.50))}ms " \
|
|
146
144
|
"p95=#{milliseconds(percentile(sorted, 0.95))}ms " \
|
|
147
145
|
"p99=#{milliseconds(percentile(sorted, 0.99))}ms"
|
|
148
|
-
ensure
|
|
149
|
-
worker&.request_shutdown
|
|
150
|
-
worker_thread&.join
|
|
151
146
|
end
|
|
152
147
|
|
|
153
148
|
# @rbs () -> void
|
|
154
149
|
def activation_cache
|
|
155
150
|
reference = CounterActor.ref("cache")
|
|
156
|
-
count.times { reference.
|
|
151
|
+
count.times { reference.async(:increment) }
|
|
157
152
|
activations = 0
|
|
158
153
|
subscriber = ActiveSupport::Notifications.subscribe("solid_objects.activation.started") do
|
|
159
154
|
activations += 1
|
|
@@ -170,7 +165,7 @@ module SolidObjectsBenchmark
|
|
|
170
165
|
|
|
171
166
|
# @rbs () -> void
|
|
172
167
|
def query_count
|
|
173
|
-
CounterActor.ref("queries").
|
|
168
|
+
CounterActor.ref("queries").async(:increment)
|
|
174
169
|
worker = SolidObjects::Worker.new
|
|
175
170
|
queries = 0
|
|
176
171
|
subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |event|
|
|
@@ -232,7 +227,7 @@ module SolidObjectsBenchmark
|
|
|
232
227
|
def enqueue_round_robin
|
|
233
228
|
actor_count = [ concurrency * 10, count ].min
|
|
234
229
|
references = Array.new(actor_count) { |index| CounterActor.ref("actor-#{index}") }
|
|
235
|
-
count.times { |index| references[index % actor_count].
|
|
230
|
+
count.times { |index| references[index % actor_count].async(:increment) }
|
|
236
231
|
end
|
|
237
232
|
|
|
238
233
|
# @rbs (SolidObjects::Worker) -> Integer
|
|
@@ -60,6 +60,7 @@ class CreateSolidObjectsTables < ActiveRecord::Migration[8.0]
|
|
|
60
60
|
definition.integer :state_version, null: false, default: 1
|
|
61
61
|
definition.bigint :next_message_sequence, null: false, default: 1
|
|
62
62
|
definition.string :activation_owner_id, limit: 36
|
|
63
|
+
definition.string :activation_token, limit: 36
|
|
63
64
|
definition.datetime :activation_expires_at, precision: 6
|
|
64
65
|
definition.bigint :activation_generation, null: false, default: 0
|
|
65
66
|
definition.datetime :activated_at, precision: 6
|
|
@@ -75,12 +76,17 @@ class CreateSolidObjectsTables < ActiveRecord::Migration[8.0]
|
|
|
75
76
|
definition.check_constraint "state_version > 0", name: "chk_so_instances_state_version"
|
|
76
77
|
definition.check_constraint "next_message_sequence > 0", name: "chk_so_instances_sequence"
|
|
77
78
|
definition.check_constraint "activation_generation >= 0", name: "chk_so_instances_generation"
|
|
79
|
+
definition.check_constraint(
|
|
80
|
+
"(activation_owner_id IS NULL AND activation_token IS NULL) OR " \
|
|
81
|
+
"(activation_owner_id IS NOT NULL AND activation_token IS NOT NULL)",
|
|
82
|
+
name: "chk_so_instances_activation_owner"
|
|
83
|
+
)
|
|
78
84
|
end
|
|
79
85
|
|
|
80
86
|
add_foreign_key table(:instances),
|
|
81
87
|
table(:processes),
|
|
82
88
|
column: :activation_owner_id,
|
|
83
|
-
on_delete: :
|
|
89
|
+
on_delete: :restrict,
|
|
84
90
|
name: "fk_so_instances_owner"
|
|
85
91
|
end
|
|
86
92
|
|
|
@@ -102,10 +108,12 @@ class CreateSolidObjectsTables < ActiveRecord::Migration[8.0]
|
|
|
102
108
|
definition.string :idempotency_key, limit: 191
|
|
103
109
|
json_column definition, :result
|
|
104
110
|
json_column definition, :error
|
|
111
|
+
json_column definition, :rejection
|
|
105
112
|
definition.datetime :enqueued_at, null: false, precision: 6
|
|
106
113
|
definition.datetime :available_at, null: false, precision: 6
|
|
107
114
|
definition.datetime :started_at, precision: 6
|
|
108
115
|
definition.datetime :completed_at, precision: 6
|
|
116
|
+
definition.datetime :rejected_at, precision: 6
|
|
109
117
|
definition.datetime :last_failed_at, precision: 6
|
|
110
118
|
definition.timestamps precision: 6, null: false
|
|
111
119
|
|
|
@@ -114,10 +122,11 @@ class CreateSolidObjectsTables < ActiveRecord::Migration[8.0]
|
|
|
114
122
|
definition.index :request_id, unique: true, name: "idx_so_messages_request"
|
|
115
123
|
definition.index [ :instance_id, :idempotency_key ], unique: true, name: "idx_so_messages_idempotency"
|
|
116
124
|
definition.index [ :completed_at, :id ], name: "idx_so_messages_cleanup"
|
|
125
|
+
definition.index [ :rejected_at, :id ], name: "idx_so_messages_rejected"
|
|
117
126
|
definition.check_constraint "sequence > 0", name: "chk_so_messages_sequence"
|
|
118
127
|
definition.check_constraint "attempt_count >= 0", name: "chk_so_messages_attempt"
|
|
119
128
|
definition.check_constraint "max_attempts > 0", name: "chk_so_messages_max_attempts"
|
|
120
|
-
definition.check_constraint "message_kind IN ('
|
|
129
|
+
definition.check_constraint "message_kind IN ('async', 'sync', 'internal')", name: "chk_so_messages_kind"
|
|
121
130
|
end
|
|
122
131
|
end
|
|
123
132
|
|
|
@@ -148,6 +157,7 @@ class CreateSolidObjectsTables < ActiveRecord::Migration[8.0]
|
|
|
148
157
|
null: false,
|
|
149
158
|
foreign_key: { to_table: table(:instances), on_delete: :cascade, name: "fk_so_claimed_instance" }
|
|
150
159
|
definition.string :process_id, limit: 36
|
|
160
|
+
definition.string :activation_token, limit: 36
|
|
151
161
|
definition.bigint :activation_generation, null: false
|
|
152
162
|
definition.datetime :claimed_at, null: false, precision: 6
|
|
153
163
|
|
|
@@ -155,12 +165,14 @@ class CreateSolidObjectsTables < ActiveRecord::Migration[8.0]
|
|
|
155
165
|
definition.index :instance_id, unique: true, name: "idx_so_claimed_instance"
|
|
156
166
|
definition.index [ :process_id, :claimed_at ], name: "idx_so_claimed_process"
|
|
157
167
|
definition.check_constraint "activation_generation > 0", name: "chk_so_claimed_generation"
|
|
168
|
+
definition.check_constraint "process_id IS NULL OR activation_token IS NOT NULL",
|
|
169
|
+
name: "chk_so_claimed_activation_owner"
|
|
158
170
|
end
|
|
159
171
|
|
|
160
172
|
add_foreign_key table(:claimed_messages),
|
|
161
173
|
table(:processes),
|
|
162
174
|
column: :process_id,
|
|
163
|
-
on_delete: :
|
|
175
|
+
on_delete: :restrict,
|
|
164
176
|
name: "fk_so_claimed_process"
|
|
165
177
|
end
|
|
166
178
|
|
|
@@ -21,4 +21,4 @@ Message handlers themselves can run more than once. Sequential execution means o
|
|
|
21
21
|
- Pure state transitions are safe because failed transactions roll back.
|
|
22
22
|
- Application handlers need durable state guards for non-repeatable logical transitions.
|
|
23
23
|
- External systems require idempotency keys or deduplication.
|
|
24
|
-
-
|
|
24
|
+
- A synchronous invocation timing out does not cancel its durable message.
|
|
@@ -9,7 +9,10 @@ If actor A synchronously waits for actor B while B waits for A, sequential actor
|
|
|
9
9
|
|
|
10
10
|
## Decision
|
|
11
11
|
|
|
12
|
-
Actor references support durable asynchronous `
|
|
12
|
+
Actor references support durable asynchronous delivery through `async`.
|
|
13
|
+
Direct methods and `sync` are caller-facing request/response operations and
|
|
14
|
+
must not be called from actor code. Actor code uses staged actor messages or
|
|
15
|
+
request/result message pairs.
|
|
13
16
|
|
|
14
17
|
Messages staged during actor execution are delivered through a transactional outbox so they exist if and only if the source message commits.
|
|
15
18
|
|
|
@@ -9,7 +9,8 @@ Polling adds latency and database queries. PostgreSQL notifications are transact
|
|
|
9
9
|
|
|
10
10
|
## Decision
|
|
11
11
|
|
|
12
|
-
Database rows remain the only durable source of work and results. Wake-up
|
|
12
|
+
Database rows remain the only durable source of work and results. Wake-up
|
|
13
|
+
adapters only prompt workers and synchronous waiters to re-query those rows.
|
|
13
14
|
|
|
14
15
|
The interface supports:
|
|
15
16
|
|
|
@@ -20,9 +21,17 @@ The interface supports:
|
|
|
20
21
|
|
|
21
22
|
MySQL uses polling or optional Redis. SQLite uses polling plus the in-process signal; multi-host SQLite is outside its supported operating model.
|
|
22
23
|
|
|
23
|
-
The
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
The synchronous caller first attempts to claim and execute the actor locally,
|
|
25
|
+
so the normal path has no worker polling leg. When another process owns the
|
|
26
|
+
activation, coordination overhead from completion commit until the caller's
|
|
27
|
+
confirming query targets p99 at or below 100 milliseconds with a healthy
|
|
28
|
+
cross-process wake-up adapter. Polling fallback accepts up to
|
|
29
|
+
`sync_polling_interval` between observations. End-to-end latency still includes
|
|
30
|
+
earlier mailbox work and actor execution and cannot have a library-wide bound.
|
|
31
|
+
|
|
32
|
+
Direct methods and `sync` are intended for HTTP, MCP, scripts, and control
|
|
33
|
+
paths whose handler and mailbox latency fit an explicit application budget.
|
|
34
|
+
Timeout does not cancel durable work.
|
|
26
35
|
|
|
27
36
|
## Consequences
|
|
28
37
|
|