solid_objects 0.11.0 → 0.12.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 +23 -0
- data/README.md +39 -16
- data/app/controllers/solid_objects/components_controller.rb +4 -4
- data/app/models/solid_objects/message.rb +1 -1
- data/app/views/solid_objects/dead_letters/index.html.erb +1 -1
- data/app/views/solid_objects/instances/show.html.erb +1 -1
- data/benchmark/support.rb +10 -10
- data/db/migrate/20260813000000_rename_message_dispatch_columns.rb +53 -0
- data/docs/architecture.md +11 -9
- data/docs/authorization.md +2 -2
- data/docs/database-schema.md +4 -4
- data/docs/development.md +1 -1
- data/docs/migrating-existing-state.md +1 -3
- data/docs/roadmap.md +1 -1
- data/lib/solid_objects/actor.rb +43 -32
- data/lib/solid_objects/actor_channel.rb +3 -3
- data/lib/solid_objects/actor_definition.rb +9 -6
- data/lib/solid_objects/actor_view.rb +1 -1
- data/lib/solid_objects/application_write_guard.rb +1 -1
- data/lib/solid_objects/client.rb +52 -52
- data/lib/solid_objects/component_registration.rb +2 -2
- data/lib/solid_objects/component_renderer.rb +1 -1
- data/lib/solid_objects/component_subscriptions.rb +21 -21
- data/lib/solid_objects/database_adapters/sqlite.rb +7 -1
- data/lib/solid_objects/dead_letter_manager.rb +4 -4
- data/lib/solid_objects/doctor.rb +8 -8
- data/lib/solid_objects/effect_executor.rb +22 -22
- data/lib/solid_objects/errors.rb +27 -27
- data/lib/solid_objects/executor.rb +40 -38
- data/lib/solid_objects/lease.rb +3 -3
- data/lib/solid_objects/mailbox.rb +34 -27
- data/lib/solid_objects/operation_dispatcher.rb +51 -0
- data/lib/solid_objects/payload_broadcast.rb +1 -1
- data/lib/solid_objects/reference.rb +60 -29
- data/lib/solid_objects/reminder_scheduler.rb +6 -6
- data/lib/solid_objects/sync_diagnostics.rb +6 -6
- data/lib/solid_objects/turbo_stream_renderer.rb +13 -13
- data/lib/solid_objects/version.rb +1 -1
- data/lib/solid_objects.rb +1 -0
- data/sig/generated/controllers/solid_objects/components_controller.rbs +2 -2
- data/sig/generated/lib/solid_objects/actor.rbs +17 -17
- data/sig/generated/lib/solid_objects/actor_definition.rbs +4 -4
- data/sig/generated/lib/solid_objects/client.rbs +8 -8
- data/sig/generated/lib/solid_objects/component_registration.rbs +2 -2
- data/sig/generated/lib/solid_objects/component_subscriptions.rbs +8 -8
- data/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs +3 -0
- data/sig/generated/lib/solid_objects/effect_executor.rbs +2 -2
- data/sig/generated/lib/solid_objects/errors.rbs +18 -18
- data/sig/generated/lib/solid_objects/executor.rbs +10 -10
- data/sig/generated/lib/solid_objects/lease.rbs +2 -2
- data/sig/generated/lib/solid_objects/mailbox.rbs +8 -8
- data/sig/generated/lib/solid_objects/operation_dispatcher.rbs +35 -0
- data/sig/generated/lib/solid_objects/reference.rbs +7 -4
- data/sig/generated/lib/solid_objects/sync_diagnostics.rbs +2 -2
- data/sig/generated/lib/solid_objects/turbo_stream_renderer.rbs +8 -8
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 15ee987ca3dcf3419f28902e2a0c18c8444c834964b743f3e7b99cd5f6dcde33
|
|
4
|
+
data.tar.gz: 45bf4d0449184c9fbf3718ce088f8541b68049e9458ee8802947fd74bef1fadb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6b5b29a7f3052c1a04a012f348a9f8f1054500056073b34c8b2a6680f7d84db195efbe0d5d68809b33ba7bed04af1c141777a27801e6049df0863311cd948a25
|
|
7
|
+
data.tar.gz: 93e7bdeb0ee3a1a0c4f212f5da40c25d61bca88b1ea2dc87efb4b09d199068b5374452beb991baec05b573ac3e0109ca86dde37463a7cf5f28d823f02969767a
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.12.0 - 2026-08-13
|
|
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
|
+
- Use operation terminology throughout invocation persistence and diagnostics.
|
|
17
|
+
The new migration renames stored message names to `operation`, message kind
|
|
18
|
+
to `delivery_mode`, and effect callback message names to operation names.
|
|
19
|
+
- Make internal methods with more than two arguments keyword-only so dispatch,
|
|
20
|
+
persistence, component refresh, and diagnostics call sites name every value.
|
|
21
|
+
- Keep SQLite reconnect failures inside a synchronous lock deadline. Active
|
|
22
|
+
Record can reconnect after a lock error while the lock is still held, and
|
|
23
|
+
configuring WAL then raises `SQLite3::CantOpenException`; it now retries
|
|
24
|
+
within the original deadline and surfaces the documented sync timeout.
|
|
25
|
+
|
|
3
26
|
## 0.11.0 - 2026-08-11
|
|
4
27
|
|
|
5
28
|
- Add `SolidObjects.configuration.register_component`. An extension gem can now
|
data/README.md
CHANGED
|
@@ -27,7 +27,7 @@ current_count = counter.value
|
|
|
27
27
|
current_snapshot = counter.snapshot.value
|
|
28
28
|
|
|
29
29
|
# Durable fire-and-forget delivery. A worker processes it later.
|
|
30
|
-
message = counter.async(
|
|
30
|
+
message = counter.async.increment(amount: 5)
|
|
31
31
|
```
|
|
32
32
|
|
|
33
33
|
`Counter / global` is a logical identity. Like a Durable Object named with
|
|
@@ -41,10 +41,10 @@ The invocation model is the first adoption decision:
|
|
|
41
41
|
| Call | Returns | Worker fleet required? |
|
|
42
42
|
| --- | --- | --- |
|
|
43
43
|
| `counter.increment(amount: 5)` | Committed handler result | No |
|
|
44
|
-
| `counter.sync(:increment
|
|
44
|
+
| `counter.sync(timeout: 5.seconds).increment(amount: 5)` | Committed handler result | No |
|
|
45
45
|
| `counter.value` | Ordered, committed query result | No |
|
|
46
46
|
| `counter.snapshot.value` | Current committed state without a mailbox message | No |
|
|
47
|
-
| `counter.async(
|
|
47
|
+
| `counter.async.increment(amount: 5)` | `MessageReference` immediately | Yes |
|
|
48
48
|
|
|
49
49
|
Direct methods and `sync` durably enqueue the call, then the Rails caller helps
|
|
50
50
|
execute the actor through the same mailbox, lease, and fencing path as a
|
|
@@ -155,7 +155,7 @@ durable reminders owned by one logical identity:
|
|
|
155
155
|
|
|
156
156
|
```ruby
|
|
157
157
|
def schedule_expiration
|
|
158
|
-
schedule
|
|
158
|
+
schedule(at: 30.minutes.from_now).expire
|
|
159
159
|
end
|
|
160
160
|
```
|
|
161
161
|
|
|
@@ -536,7 +536,7 @@ cart.add_item(product_id: "shirt-123", quantity: 2)
|
|
|
536
536
|
items = cart.items
|
|
537
537
|
```
|
|
538
538
|
|
|
539
|
-
Use `cart.async(
|
|
539
|
+
Use `cart.async.add_item(product_id: "shirt-123", quantity: 2)` to enqueue
|
|
540
540
|
without waiting; that call returns a `SolidObjects::MessageReference`. `items`
|
|
541
541
|
is a deeply frozen JSON snapshot, so mutating it cannot bypass the actor
|
|
542
542
|
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
|
|
|
638
638
|
|
|
639
639
|
```ruby
|
|
640
640
|
message = order.async(
|
|
641
|
-
:submit,
|
|
642
|
-
|
|
643
|
-
)
|
|
641
|
+
idempotency_key: "submit-order-123",
|
|
642
|
+
authorization_context: Current.user
|
|
643
|
+
).submit
|
|
644
644
|
```
|
|
645
645
|
|
|
646
646
|
Use `available_at:` to spread bulk work or delay one message:
|
|
647
647
|
|
|
648
648
|
```ruby
|
|
649
|
-
order.async(
|
|
649
|
+
order.async(available_at: 10.minutes.from_now).evaluate
|
|
650
650
|
```
|
|
651
651
|
|
|
652
652
|
### `sync`
|
|
653
653
|
|
|
654
|
-
Use explicit `sync` when the
|
|
655
|
-
|
|
654
|
+
Use explicit `sync` when the invocation needs a timeout, idempotency key, or
|
|
655
|
+
authorization context different from the defaults:
|
|
656
656
|
|
|
657
657
|
```ruby
|
|
658
|
-
status = order.sync(
|
|
658
|
+
status = order.sync(
|
|
659
|
+
timeout: 5.seconds,
|
|
660
|
+
authorization_context: Current.user
|
|
661
|
+
).status
|
|
659
662
|
```
|
|
660
663
|
|
|
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
|
+
|
|
661
669
|
Direct calls and `sync` use the same caller-assisted execution path. A healthy
|
|
662
670
|
actor normally needs no worker round trip, making this path suitable for HTTP
|
|
663
671
|
and MCP request/response boundaries when the handler itself fits the
|
|
@@ -677,7 +685,7 @@ recover its eventual result through the durable message identity:
|
|
|
677
685
|
|
|
678
686
|
```ruby
|
|
679
687
|
begin
|
|
680
|
-
order.
|
|
688
|
+
order.sync(timeout: 250.milliseconds).submit
|
|
681
689
|
rescue SolidObjects::SyncTimeout => error
|
|
682
690
|
result = error.message_reference.wait(
|
|
683
691
|
timeout: 5.seconds,
|
|
@@ -700,6 +708,17 @@ Actor code cannot use direct calls or `sync` on another actor; synchronous
|
|
|
700
708
|
actor-to-actor waits can deadlock in cycles. Use `async` or `send_to` and a
|
|
701
709
|
result message.
|
|
702
710
|
|
|
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
|
+
|
|
703
722
|
### Domain rejection
|
|
704
723
|
|
|
705
724
|
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:
|
|
|
831
850
|
|
|
832
851
|
```ruby
|
|
833
852
|
def schedule_evaluation
|
|
834
|
-
schedule
|
|
853
|
+
schedule(
|
|
854
|
+
at: 1.hour.from_now,
|
|
855
|
+
every: 1.hour,
|
|
856
|
+
missed: :latest
|
|
857
|
+
).evaluate(account_id:)
|
|
835
858
|
end
|
|
836
859
|
```
|
|
837
860
|
|
|
@@ -852,7 +875,7 @@ once. It also means this is a data-loss bug:
|
|
|
852
875
|
# Wrong. Every entry overwrites the previous entry's alarm.
|
|
853
876
|
def add(entry:)
|
|
854
877
|
self.entries = entries + [ entry ]
|
|
855
|
-
schedule
|
|
878
|
+
schedule(at: entry.fetch("wait_until")).deliver
|
|
856
879
|
end
|
|
857
880
|
```
|
|
858
881
|
|
|
@@ -882,7 +905,7 @@ def arm_next
|
|
|
882
905
|
earliest = entries.first
|
|
883
906
|
return unless earliest
|
|
884
907
|
|
|
885
|
-
schedule
|
|
908
|
+
schedule(at: Time.at(earliest.fetch("wait_until"))).deliver
|
|
886
909
|
end
|
|
887
910
|
```
|
|
888
911
|
|
|
@@ -55,7 +55,7 @@ module SolidObjects
|
|
|
55
55
|
"target" => registration.dom_id,
|
|
56
56
|
"revision" => "#{snapshot.instance_id}:#{snapshot.revision}",
|
|
57
57
|
"refresh_method" => registration.refresh_method,
|
|
58
|
-
"html" => component_frame(registration
|
|
58
|
+
"html" => component_frame(registration:, snapshot:, rendered:)
|
|
59
59
|
}
|
|
60
60
|
end
|
|
61
61
|
response.headers["Cache-Control"] = "private, no-store"
|
|
@@ -118,7 +118,7 @@ module SolidObjects
|
|
|
118
118
|
).call
|
|
119
119
|
response.headers["Cache-Control"] = "private, no-store"
|
|
120
120
|
payload[:outcome] = "rendered"
|
|
121
|
-
render html: component_frame(registration
|
|
121
|
+
render html: component_frame(registration:, snapshot:, rendered:)
|
|
122
122
|
rescue Unauthorized
|
|
123
123
|
payload[:outcome] = "unauthorized"
|
|
124
124
|
head :forbidden
|
|
@@ -182,8 +182,8 @@ module SolidObjects
|
|
|
182
182
|
end
|
|
183
183
|
end
|
|
184
184
|
|
|
185
|
-
# @rbs (ComponentRegistration, ActorSnapshot, untyped) -> String
|
|
186
|
-
def component_frame(registration
|
|
185
|
+
# @rbs (registration: ComponentRegistration, snapshot: ActorSnapshot, rendered: untyped) -> String
|
|
186
|
+
def component_frame(registration:, snapshot:, rendered:)
|
|
187
187
|
revision = "#{snapshot.instance_id}:#{snapshot.revision}"
|
|
188
188
|
%(<turbo-frame id="#{registration.dom_id}" data-solid-objects-revision="#{revision}" data-solid-objects-refresh="#{registration.refresh_method}">#{rendered}</turbo-frame>).html_safe
|
|
189
189
|
end
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
<% @dead_letters.each do |dead_letter| %>
|
|
16
16
|
<tr>
|
|
17
17
|
<td><%= dead_letter.actor_type %>(<%= dead_letter.actor_id %>)</td>
|
|
18
|
-
<td><%= dead_letter.
|
|
18
|
+
<td><%= dead_letter.operation %></td>
|
|
19
19
|
<td><%= dead_letter.attempts %></td>
|
|
20
20
|
<td><%= dead_letter.exception_class %>: <%= dead_letter.exception_message %></td>
|
|
21
21
|
<td><%= dead_letter.last_failed_at %></td>
|
data/benchmark/support.rb
CHANGED
|
@@ -98,13 +98,13 @@ module SolidObjectsBenchmark
|
|
|
98
98
|
def enqueue
|
|
99
99
|
reference = CounterActor.ref("enqueue")
|
|
100
100
|
measure("enqueue #{count} messages") do
|
|
101
|
-
count.times { reference.async
|
|
101
|
+
count.times { reference.async.increment }
|
|
102
102
|
end
|
|
103
103
|
end
|
|
104
104
|
|
|
105
105
|
# @rbs () -> void
|
|
106
106
|
def claim
|
|
107
|
-
count.times { |index| CounterActor.ref("claim-#{index}").async
|
|
107
|
+
count.times { |index| CounterActor.ref("claim-#{index}").async.increment }
|
|
108
108
|
process_registry = SolidObjects::ProcessRegistry.new
|
|
109
109
|
owner_id = process_registry.register.id
|
|
110
110
|
activation_manager = SolidObjects::ActivationManager.new(owner_id:)
|
|
@@ -137,7 +137,7 @@ module SolidObjectsBenchmark
|
|
|
137
137
|
|
|
138
138
|
# @rbs () -> void
|
|
139
139
|
def cold_actors
|
|
140
|
-
count.times { |index| CounterActor.ref("cold-#{index}").async
|
|
140
|
+
count.times { |index| CounterActor.ref("cold-#{index}").async.increment }
|
|
141
141
|
worker = SolidObjects::Worker.new
|
|
142
142
|
measure("process #{count} cold actors") { drain(worker) }
|
|
143
143
|
ensure
|
|
@@ -147,7 +147,7 @@ module SolidObjectsBenchmark
|
|
|
147
147
|
# @rbs () -> void
|
|
148
148
|
def hot_actor
|
|
149
149
|
reference = CounterActor.ref("hot")
|
|
150
|
-
count.times { reference.async
|
|
150
|
+
count.times { reference.async.increment }
|
|
151
151
|
worker = SolidObjects::Worker.new
|
|
152
152
|
measure("process #{count} messages for one hot actor") { drain(worker) }
|
|
153
153
|
ensure
|
|
@@ -172,7 +172,7 @@ module SolidObjectsBenchmark
|
|
|
172
172
|
|
|
173
173
|
count.times do |index|
|
|
174
174
|
started_at = monotonic_now
|
|
175
|
-
CounterActor.ref("sync-#{index}").sync(
|
|
175
|
+
CounterActor.ref("sync-#{index}").sync(timeout: 5).count
|
|
176
176
|
samples << monotonic_now - started_at
|
|
177
177
|
end
|
|
178
178
|
|
|
@@ -211,7 +211,7 @@ module SolidObjectsBenchmark
|
|
|
211
211
|
# @rbs () -> void
|
|
212
212
|
def activation_cache
|
|
213
213
|
reference = CounterActor.ref("cache")
|
|
214
|
-
count.times { reference.async
|
|
214
|
+
count.times { reference.async.increment }
|
|
215
215
|
activations = 0
|
|
216
216
|
subscriber = ActiveSupport::Notifications.subscribe("solid_objects.activation.started") do
|
|
217
217
|
activations += 1
|
|
@@ -303,7 +303,7 @@ module SolidObjectsBenchmark
|
|
|
303
303
|
# Runs the turn on the measuring thread, so nothing else can contribute.
|
|
304
304
|
# @rbs () -> Integer
|
|
305
305
|
def message_turn_query_count
|
|
306
|
-
CounterActor.ref("queries").async
|
|
306
|
+
CounterActor.ref("queries").async.increment
|
|
307
307
|
worker = SolidObjects::Worker.new
|
|
308
308
|
count_queries { worker.run_once }
|
|
309
309
|
ensure
|
|
@@ -322,8 +322,8 @@ module SolidObjectsBenchmark
|
|
|
322
322
|
reference = CounterActor.ref("sync-queries")
|
|
323
323
|
worker = SolidObjects::Worker.new
|
|
324
324
|
runner = Thread.new { worker.run }
|
|
325
|
-
reference.sync
|
|
326
|
-
count_queries { reference.sync
|
|
325
|
+
reference.sync.increment
|
|
326
|
+
count_queries { reference.sync.increment }
|
|
327
327
|
ensure
|
|
328
328
|
worker&.request_shutdown
|
|
329
329
|
runner&.join(5)
|
|
@@ -428,7 +428,7 @@ module SolidObjectsBenchmark
|
|
|
428
428
|
def enqueue_round_robin
|
|
429
429
|
actor_count = [ concurrency * 10, count ].min
|
|
430
430
|
references = Array.new(actor_count) { |index| CounterActor.ref("actor-#{index}") }
|
|
431
|
-
count.times { |index| references[index % actor_count].async
|
|
431
|
+
count.times { |index| references[index % actor_count].async.increment }
|
|
432
432
|
end
|
|
433
433
|
|
|
434
434
|
# @rbs (SolidObjects::Worker) -> Integer
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
class RenameMessageDispatchColumns < ActiveRecord::Migration[8.0]
|
|
4
|
+
# @rbs () -> void
|
|
5
|
+
def up
|
|
6
|
+
remove_check_constraint messages_table, name: "chk_so_messages_kind"
|
|
7
|
+
rename_column messages_table, :message_name, :operation
|
|
8
|
+
rename_column messages_table, :message_kind, :delivery_mode
|
|
9
|
+
rename_column reminders_table, :message_name, :operation
|
|
10
|
+
rename_column effects_table, :success_message_name, :success_operation
|
|
11
|
+
rename_column effects_table, :failure_message_name, :failure_operation
|
|
12
|
+
rename_column dead_letters_table, :message_name, :operation
|
|
13
|
+
add_check_constraint messages_table,
|
|
14
|
+
"delivery_mode IN ('async', 'sync', 'internal')",
|
|
15
|
+
name: "chk_so_messages_delivery_mode"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @rbs () -> void
|
|
19
|
+
def down
|
|
20
|
+
remove_check_constraint messages_table, name: "chk_so_messages_delivery_mode"
|
|
21
|
+
rename_column messages_table, :operation, :message_name
|
|
22
|
+
rename_column messages_table, :delivery_mode, :message_kind
|
|
23
|
+
rename_column reminders_table, :operation, :message_name
|
|
24
|
+
rename_column effects_table, :success_operation, :success_message_name
|
|
25
|
+
rename_column effects_table, :failure_operation, :failure_message_name
|
|
26
|
+
rename_column dead_letters_table, :operation, :message_name
|
|
27
|
+
add_check_constraint messages_table,
|
|
28
|
+
"message_kind IN ('async', 'sync', 'internal')",
|
|
29
|
+
name: "chk_so_messages_kind"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
# @rbs () -> String
|
|
35
|
+
def messages_table
|
|
36
|
+
SolidObjects.table_name(:messages)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @rbs () -> String
|
|
40
|
+
def reminders_table
|
|
41
|
+
SolidObjects.table_name(:reminders)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @rbs () -> String
|
|
45
|
+
def effects_table
|
|
46
|
+
SolidObjects.table_name(:effects)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @rbs () -> String
|
|
50
|
+
def dead_letters_table
|
|
51
|
+
SolidObjects.table_name(:dead_letters)
|
|
52
|
+
end
|
|
53
|
+
end
|
data/docs/architecture.md
CHANGED
|
@@ -63,14 +63,15 @@ The registry maps a stable persisted actor type string to a Ruby actor class. Re
|
|
|
63
63
|
A reference contains actor type and normalized actor ID. It is cheap,
|
|
64
64
|
serializable as data, and does not imply an active Ruby object. Declared
|
|
65
65
|
message, query, and attribute methods use synchronous caller-assisted
|
|
66
|
-
invocation. `sync`
|
|
67
|
-
while `async`
|
|
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.
|
|
68
69
|
`destroy` is a reserved synchronous reference operation. Every path authorizes
|
|
69
70
|
through the client.
|
|
70
71
|
|
|
71
72
|
### Client and mailbox
|
|
72
73
|
|
|
73
|
-
The client finds or creates the actor instance and atomically allocates a sequence. It inserts one durable message-history row and one ready-membership row. It validates
|
|
74
|
+
The client finds or creates the actor instance and atomically allocates a sequence. It inserts one durable message-history row and one ready-membership row. It validates operations and JSON payloads before writing and enforces idempotency-key uniqueness, payload limits, and the per-actor mailbox cap. It also authorizes and coordinates actor destruction. Distributed rate limiting and global admission control are not implemented.
|
|
74
75
|
|
|
75
76
|
Message execution state is table membership, not a status column. The durable message remains for results, retention, and diagnostics. Only live work occupies `ready_messages` or `claimed_messages`, so completed history cannot inflate the polling index.
|
|
76
77
|
|
|
@@ -155,8 +156,9 @@ Public instance methods declared on the actor are messages. Declare helpers as
|
|
|
155
156
|
private or protected. Messages and queries are exposed as methods on a
|
|
156
157
|
reference through the same synchronous caller-assisted path. Returned state
|
|
157
158
|
snapshots are deeply frozen. Use `async` for durable fire-and-forget delivery
|
|
158
|
-
and `sync`
|
|
159
|
-
|
|
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.
|
|
160
162
|
|
|
161
163
|
`reference.snapshot` is the explicit unordered read path. It invokes query
|
|
162
164
|
authorization, reads the most recently committed instance state without
|
|
@@ -183,7 +185,7 @@ with application Active Record writes prevented.
|
|
|
183
185
|
Enqueue uses one transaction:
|
|
184
186
|
|
|
185
187
|
1. Resolve actor class from the registry.
|
|
186
|
-
2. Validate authorization,
|
|
188
|
+
2. Validate authorization, operation, actor ID, arguments, and size.
|
|
187
189
|
3. `INSERT ... ON CONFLICT` the actor instance if missing.
|
|
188
190
|
4. Lock the instance row.
|
|
189
191
|
5. Enforce the mailbox limit.
|
|
@@ -426,7 +428,7 @@ An effect handler receives JSON arguments plus an effect context. Outcome messag
|
|
|
426
428
|
Actor code sends to another actor through a staged outbox:
|
|
427
429
|
|
|
428
430
|
```ruby
|
|
429
|
-
send_to
|
|
431
|
+
send_to(InventoryActor.ref("sku-123")).reserve(order_id: id, quantity: 2)
|
|
430
432
|
```
|
|
431
433
|
|
|
432
434
|
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
|
|
|
436
438
|
A reminder record contains actor identity, a reminder name, target message, JSON arguments, next run time, optional interval, status, and occurrence counter.
|
|
437
439
|
|
|
438
440
|
```ruby
|
|
439
|
-
schedule
|
|
441
|
+
schedule(at: 30.minutes.from_now).expire
|
|
440
442
|
```
|
|
441
443
|
|
|
442
444
|
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
|
|
|
467
469
|
|
|
468
470
|
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.
|
|
469
471
|
|
|
470
|
-
Large repairs use `async(
|
|
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.
|
|
471
473
|
|
|
472
474
|
## Realtime integration
|
|
473
475
|
|
data/docs/authorization.md
CHANGED
|
@@ -64,7 +64,7 @@ retain an empty arguments hash. This lets a policy authorize a projection such
|
|
|
64
64
|
as one seat or player:
|
|
65
65
|
|
|
66
66
|
```ruby
|
|
67
|
-
configuration.authorize_query = lambda do |actor_type:, actor_id:,
|
|
67
|
+
configuration.authorize_query = lambda do |actor_type:, actor_id:, operation:, arguments:, authorization_context:|
|
|
68
68
|
user = authorization_context
|
|
69
69
|
player_id = arguments["player_id"]
|
|
70
70
|
|
|
@@ -126,7 +126,7 @@ SolidObjects.configure do |configuration|
|
|
|
126
126
|
end
|
|
127
127
|
```
|
|
128
128
|
|
|
129
|
-
The policy receives normalized actor type and ID strings,
|
|
129
|
+
The policy receives normalized actor type and ID strings, operation and
|
|
130
130
|
arguments where relevant, and the context supplied by the caller. Avoid
|
|
131
131
|
authorizing from arguments alone; bind the actor identity to the authenticated
|
|
132
132
|
principal and tenant.
|
data/docs/database-schema.md
CHANGED
|
@@ -37,9 +37,9 @@ scheduled reminder, unresolved outbox, or dead letter before deletion.
|
|
|
37
37
|
|
|
38
38
|
### `messages`
|
|
39
39
|
|
|
40
|
-
Durable immutable invocation identity
|
|
41
|
-
count, request/idempotency IDs, result/error,
|
|
42
|
-
execution timestamps. A terminal domain rejection stores a structured
|
|
40
|
+
Durable immutable invocation identity, selected operation, delivery mode, and
|
|
41
|
+
arguments plus sequence, attempt count, request/idempotency IDs, result/error,
|
|
42
|
+
requested availability, and execution timestamps. A terminal domain rejection stores a structured
|
|
43
43
|
code/message/details document and rejection time. The table intentionally has
|
|
44
44
|
no status column.
|
|
45
45
|
|
|
@@ -85,7 +85,7 @@ limit recurrence intervals, missed-work policies, and statuses.
|
|
|
85
85
|
### `effects`
|
|
86
86
|
|
|
87
87
|
Transactional external-effect outbox. It stores stable effect UUID, arguments,
|
|
88
|
-
optional actor outcome
|
|
88
|
+
optional actor outcome operations, attempts, claim ownership, result/error, and
|
|
89
89
|
completion time. Claim ownership references the process registry.
|
|
90
90
|
Status/availability/ID drives delivery; completion/ID drives cleanup.
|
|
91
91
|
|
data/docs/development.md
CHANGED
|
@@ -56,7 +56,7 @@ Use `drain_solid_objects` to process actor, reminder, effect, callback, and
|
|
|
56
56
|
broadcast work to a deterministic fixed point without arbitrary sleeps:
|
|
57
57
|
|
|
58
58
|
```ruby
|
|
59
|
-
message = Counter.ref("test").async
|
|
59
|
+
message = Counter.ref("test").async.increment
|
|
60
60
|
|
|
61
61
|
assert_equal 1, drain_solid_objects
|
|
62
62
|
assert_equal "completed", message.status
|
|
@@ -71,11 +71,9 @@ Give every bootstrap call an idempotency key derived from the legacy record:
|
|
|
71
71
|
|
|
72
72
|
```ruby
|
|
73
73
|
session.actor.async(
|
|
74
|
-
:bootstrap,
|
|
75
|
-
answers: legacy.answers,
|
|
76
74
|
idempotency_key: "legacy-assessment:#{legacy.id}",
|
|
77
75
|
available_at: jittered_time
|
|
78
|
-
)
|
|
76
|
+
).bootstrap(answers: legacy.answers)
|
|
79
77
|
```
|
|
80
78
|
|
|
81
79
|
Spread large backfills over a dispatch window and monitor mailbox age,
|
data/docs/roadmap.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
- Rails engine, install generator, migration, and CLI
|
|
6
6
|
- Explicit actor registry, references, JSON state, and state migrations
|
|
7
|
-
-
|
|
7
|
+
- Fluent direct synchronous RPC, configured `sync`, and durable `async`
|
|
8
8
|
- Durable message history plus ready/claimed membership tables
|
|
9
9
|
- Concurrent sequence allocation and actor creation
|
|
10
10
|
- Activation leases, renewal, unique activation tokens, generations, and
|
data/lib/solid_objects/actor.rb
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
module SolidObjects
|
|
4
4
|
class Actor
|
|
5
|
-
EffectIntent = Data.define(:name, :arguments, :
|
|
5
|
+
EffectIntent = Data.define(:name, :arguments, :success_operation, :failure_operation)
|
|
6
6
|
CommitActionIntent = Data.define(:name, :arguments)
|
|
7
7
|
ReminderIntent = Data.define(:name, :at, :arguments, :interval_seconds, :missed_policy)
|
|
8
|
-
OutboundMessageIntent = Data.define(:actor_type, :actor_id, :
|
|
8
|
+
OutboundMessageIntent = Data.define(:actor_type, :actor_id, :operation, :arguments, :available_at, :idempotency_key)
|
|
9
9
|
|
|
10
10
|
class << self
|
|
11
11
|
# @rbs (Class) -> void
|
|
@@ -72,7 +72,7 @@ module SolidObjects
|
|
|
72
72
|
|
|
73
73
|
# @rbs (from: Integer, to: Integer) { (Hash[String, untyped]) -> Hash[String, untyped] } -> ActorDefinition::StateMigration
|
|
74
74
|
def migrate_state(from:, to:, &block)
|
|
75
|
-
definition.add_state_migration(from
|
|
75
|
+
definition.add_state_migration(from:, to:, block:)
|
|
76
76
|
end
|
|
77
77
|
|
|
78
78
|
# @rbs () { () -> untyped } -> Proc
|
|
@@ -177,8 +177,8 @@ module SolidObjects
|
|
|
177
177
|
EffectIntent.new(
|
|
178
178
|
name: name.to_s,
|
|
179
179
|
arguments: Serialization.dump(arguments),
|
|
180
|
-
|
|
181
|
-
|
|
180
|
+
success_operation: on_success&.to_s,
|
|
181
|
+
failure_operation: on_failure&.to_s
|
|
182
182
|
).tap do |intent|
|
|
183
183
|
effect_intents << intent
|
|
184
184
|
end
|
|
@@ -196,8 +196,8 @@ module SolidObjects
|
|
|
196
196
|
nil
|
|
197
197
|
end
|
|
198
198
|
|
|
199
|
-
# @rbs (
|
|
200
|
-
def schedule(
|
|
199
|
+
# @rbs (at: Time, ?every: Numeric?, ?missed: Symbol | String) -> OperationDispatcher
|
|
200
|
+
def schedule(at:, every: nil, missed: :latest)
|
|
201
201
|
interval_seconds = every&.to_f
|
|
202
202
|
if interval_seconds && !interval_seconds.positive?
|
|
203
203
|
raise ArgumentError, "reminder interval must be positive"
|
|
@@ -207,31 +207,42 @@ module SolidObjects
|
|
|
207
207
|
raise ArgumentError, "missed reminder policy must be all or latest"
|
|
208
208
|
end
|
|
209
209
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
210
|
+
OperationDispatcher.new(
|
|
211
|
+
actor_type: self.class.actor_type,
|
|
212
|
+
handlers: self.class.definition.messages
|
|
213
|
+
) do |operation, arguments|
|
|
214
|
+
ReminderIntent.new(
|
|
215
|
+
name: operation.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
|
|
218
224
|
end
|
|
219
|
-
nil
|
|
220
225
|
end
|
|
221
226
|
|
|
222
|
-
# @rbs (Reference,
|
|
223
|
-
def send_to(reference,
|
|
224
|
-
|
|
225
|
-
|
|
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 |operation, arguments|
|
|
234
|
+
stage_outbound_message(reference:, operation:, arguments:, available_at:, idempotency_key:)
|
|
235
|
+
nil
|
|
236
|
+
end
|
|
226
237
|
end
|
|
227
238
|
|
|
228
239
|
# @rbs (Symbol | String, Hash[String, untyped]) -> untyped
|
|
229
|
-
def invoke(
|
|
230
|
-
handler = self.class.definition.messages[
|
|
231
|
-
self.class.definition.queries[
|
|
232
|
-
raise UnknownMessage, "unknown
|
|
240
|
+
def invoke(operation, arguments)
|
|
241
|
+
handler = self.class.definition.messages[operation.to_sym] ||
|
|
242
|
+
self.class.definition.queries[operation.to_sym]
|
|
243
|
+
raise UnknownMessage, "unknown operation #{operation.inspect} for #{self.class.actor_type}" unless handler
|
|
233
244
|
|
|
234
|
-
guard_application_writes(
|
|
245
|
+
guard_application_writes(operation.to_s) do
|
|
235
246
|
instance_exec(**keyword_arguments(arguments), &handler.block)
|
|
236
247
|
end
|
|
237
248
|
end
|
|
@@ -270,12 +281,12 @@ module SolidObjects
|
|
|
270
281
|
end
|
|
271
282
|
end
|
|
272
283
|
|
|
273
|
-
# @rbs (Reference, Symbol | String, Hash[Symbol | String, untyped], ?available_at: Time?, idempotency_key: String?) -> OutboundMessageIntent
|
|
274
|
-
def stage_outbound_message(reference
|
|
284
|
+
# @rbs (reference: Reference, operation: Symbol | String, arguments: Hash[Symbol | String, untyped], ?available_at: Time?, idempotency_key: String?) -> OutboundMessageIntent
|
|
285
|
+
def stage_outbound_message(reference:, operation:, arguments:, available_at: nil, idempotency_key: nil)
|
|
275
286
|
OutboundMessageIntent.new(
|
|
276
287
|
actor_type: reference.actor_type,
|
|
277
288
|
actor_id: reference.actor_id,
|
|
278
|
-
|
|
289
|
+
operation: operation.to_s,
|
|
279
290
|
arguments: Serialization.dump(arguments),
|
|
280
291
|
available_at:,
|
|
281
292
|
idempotency_key:
|
|
@@ -333,11 +344,11 @@ module SolidObjects
|
|
|
333
344
|
end
|
|
334
345
|
|
|
335
346
|
# @rbs (Symbol | String?) -> void
|
|
336
|
-
def validate_effect_callback!(
|
|
337
|
-
return unless
|
|
338
|
-
return if self.class.definition.messages.key?(
|
|
347
|
+
def validate_effect_callback!(operation)
|
|
348
|
+
return unless operation
|
|
349
|
+
return if self.class.definition.messages.key?(operation.to_sym)
|
|
339
350
|
|
|
340
|
-
raise UnknownMessage, "unknown effect callback
|
|
351
|
+
raise UnknownMessage, "unknown effect callback operation #{operation.inspect}"
|
|
341
352
|
end
|
|
342
353
|
end
|
|
343
354
|
end
|