solid_objects 0.11.0 → 0.12.1
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 +38 -0
- data/README.md +62 -20
- data/app/controllers/solid_objects/components_controller.rb +4 -4
- data/app/models/solid_objects/broadcast.rb +8 -0
- 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 +14 -11
- data/docs/authorization.md +2 -2
- data/docs/database-schema.md +6 -6
- data/docs/development.md +13 -1
- data/docs/migrating-existing-state.md +1 -3
- data/docs/realtime.md +25 -3
- data/docs/roadmap.md +9 -5
- data/docs/security.md +8 -0
- data/examples/application/app/actors/shopping_cart_actor.rb +3 -3
- data/lib/solid_objects/actor.rb +49 -37
- data/lib/solid_objects/actor_channel.rb +10 -7
- data/lib/solid_objects/actor_definition.rb +24 -9
- data/lib/solid_objects/actor_view.rb +7 -2
- 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 +30 -22
- data/lib/solid_objects/errors.rb +30 -27
- data/lib/solid_objects/executor.rb +47 -39
- 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 +34 -20
- data/lib/solid_objects/sync_diagnostics.rb +6 -6
- data/lib/solid_objects/test_helper.rb +16 -0
- data/lib/solid_objects/turbo_stream_renderer.rb +16 -16
- 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 +19 -19
- data/sig/generated/lib/solid_objects/actor_definition.rbs +13 -6
- 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 +21 -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/reminder_scheduler.rbs +9 -6
- data/sig/generated/lib/solid_objects/sync_diagnostics.rbs +2 -2
- data/sig/generated/lib/solid_objects/test_helper.rbs +3 -0
- data/sig/generated/lib/solid_objects/turbo_stream_renderer.rbs +8 -8
- data/sig/generated/models/solid_objects/broadcast.rbs +2 -0
- 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: 7e89f40dd095ec1d8934ff47872f3638ca441203186a7a1562dc9f603c939150
|
|
4
|
+
data.tar.gz: 23389e8737fcbfc816ca41aedf56f4b83baa6769fc2c54f6d9ca3622c67ca135
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d28e8e826c045dc546a13eb34631c692281b82b10a55205f0f6b3a17b77a1a2144bbfb786c69c86d701d42d356c65da4c26d7dd2f25a5538f9743e4405aa53b1
|
|
7
|
+
data.tar.gz: 34c89447e1592ea056d511db1fbf01de898c1563c34616a7b80058b0c1bb649b3a4e01cf7f1fad6de35bedc82e484c0a8c96b21aa0be1d959b94c3492fd3d3c4
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,43 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.12.1 - 2026-08-13
|
|
4
|
+
|
|
5
|
+
- Add invalidation-only observables with `broadcast: :invalidation`. They still
|
|
6
|
+
detect changes and refresh reactive components, but persist `{}` and send no
|
|
7
|
+
scalar value over Action Cable. Document that ordinary observable values are
|
|
8
|
+
shared with every authorized actor subscriber and that subscriber-specific
|
|
9
|
+
state belongs in a payload projection.
|
|
10
|
+
- **Breaking:** include the originally staged `arguments:` in effect success
|
|
11
|
+
and failure callbacks so actors can correlate concurrent effects.
|
|
12
|
+
- Accept identifier-style rejection codes, including camelCase and symbols,
|
|
13
|
+
and fail malformed codes once with non-retryable
|
|
14
|
+
`SolidObjects::InvalidRejectionCode` diagnostics.
|
|
15
|
+
- Add `run_due_reminders(now:)` to `SolidObjects::TestHelper` for deterministic
|
|
16
|
+
reminder tests without sleeping or mutating runtime rows.
|
|
17
|
+
|
|
18
|
+
## 0.12.0 - 2026-08-13
|
|
19
|
+
|
|
20
|
+
- Replace positional actor dispatch with fluent operation selection. Direct
|
|
21
|
+
committed calls remain `account.disable(...)`; configured committed calls
|
|
22
|
+
use `account.sync(timeout: ...).status`; asynchronous calls use
|
|
23
|
+
`account.async(...).disable(...)`; actor outbox delivery uses
|
|
24
|
+
`send_to(account, ...).disable(...)`; and reminders use
|
|
25
|
+
`schedule(at: ...).evaluate(...)`. Delivery and reminder options are now
|
|
26
|
+
unambiguously separate from actor message arguments. The former positional
|
|
27
|
+
`async`, `sync`, `send_to`, and `schedule` forms are removed.
|
|
28
|
+
- Validate fluent operations before enqueueing or staging them. Direct and
|
|
29
|
+
configured synchronous calls accept messages and queries, while `async`,
|
|
30
|
+
`send_to`, and `schedule` accept public actor messages only.
|
|
31
|
+
- Use operation terminology throughout invocation persistence and diagnostics.
|
|
32
|
+
The new migration renames stored message names to `operation`, message kind
|
|
33
|
+
to `delivery_mode`, and effect callback message names to operation names.
|
|
34
|
+
- Make internal methods with more than two arguments keyword-only so dispatch,
|
|
35
|
+
persistence, component refresh, and diagnostics call sites name every value.
|
|
36
|
+
- Keep SQLite reconnect failures inside a synchronous lock deadline. Active
|
|
37
|
+
Record can reconnect after a lock error while the lock is still held, and
|
|
38
|
+
configuring WAL then raises `SQLite3::CantOpenException`; it now retries
|
|
39
|
+
within the original deadline and surfaces the documented sync timeout.
|
|
40
|
+
|
|
3
41
|
## 0.11.0 - 2026-08-11
|
|
4
42
|
|
|
5
43
|
- 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
|
|
|
@@ -205,6 +205,21 @@ dependencies changes:
|
|
|
205
205
|
<% end %>
|
|
206
206
|
```
|
|
207
207
|
|
|
208
|
+
An observable's value is shared with every authorized subscriber by default
|
|
209
|
+
and is stored in `solid_objects_broadcasts`. Use an invalidation-only observable
|
|
210
|
+
for component dependencies whose value is private or subscriber-specific:
|
|
211
|
+
|
|
212
|
+
```ruby
|
|
213
|
+
observable :player_one, broadcast: :invalidation do
|
|
214
|
+
player_in_seat(1)
|
|
215
|
+
end
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Its value is still available to the authorized component renderer, but the
|
|
219
|
+
durable row and Action Cable frame carry only invalidation metadata. It cannot
|
|
220
|
+
be rendered as a scalar `<span>`. Put per-viewer state in `broadcast_payload`,
|
|
221
|
+
which computes a fresh projection for each connection.
|
|
222
|
+
|
|
208
223
|
Component names can repeat when each instance has a stable key. Signed
|
|
209
224
|
JSON-compatible locals let one conventional partial render the matching
|
|
210
225
|
projection:
|
|
@@ -536,7 +551,7 @@ cart.add_item(product_id: "shirt-123", quantity: 2)
|
|
|
536
551
|
items = cart.items
|
|
537
552
|
```
|
|
538
553
|
|
|
539
|
-
Use `cart.async(
|
|
554
|
+
Use `cart.async.add_item(product_id: "shirt-123", quantity: 2)` to enqueue
|
|
540
555
|
without waiting; that call returns a `SolidObjects::MessageReference`. `items`
|
|
541
556
|
is a deeply frozen JSON snapshot, so mutating it cannot bypass the actor
|
|
542
557
|
mailbox. State changes must go through public actor methods or explicit
|
|
@@ -638,26 +653,34 @@ Use `async` for durable fire-and-forget work. It returns a
|
|
|
638
653
|
|
|
639
654
|
```ruby
|
|
640
655
|
message = order.async(
|
|
641
|
-
:submit,
|
|
642
|
-
|
|
643
|
-
)
|
|
656
|
+
idempotency_key: "submit-order-123",
|
|
657
|
+
authorization_context: Current.user
|
|
658
|
+
).submit
|
|
644
659
|
```
|
|
645
660
|
|
|
646
661
|
Use `available_at:` to spread bulk work or delay one message:
|
|
647
662
|
|
|
648
663
|
```ruby
|
|
649
|
-
order.async(
|
|
664
|
+
order.async(available_at: 10.minutes.from_now).evaluate
|
|
650
665
|
```
|
|
651
666
|
|
|
652
667
|
### `sync`
|
|
653
668
|
|
|
654
|
-
Use explicit `sync` when the
|
|
655
|
-
|
|
669
|
+
Use explicit `sync` when the invocation needs a timeout, idempotency key, or
|
|
670
|
+
authorization context different from the defaults:
|
|
656
671
|
|
|
657
672
|
```ruby
|
|
658
|
-
status = order.sync(
|
|
673
|
+
status = order.sync(
|
|
674
|
+
timeout: 5.seconds,
|
|
675
|
+
authorization_context: Current.user
|
|
676
|
+
).status
|
|
659
677
|
```
|
|
660
678
|
|
|
679
|
+
Delivery configuration belongs on `async(...)` or `sync(...)` before the
|
|
680
|
+
operation. Keywords on the final method call are always actor message
|
|
681
|
+
arguments, so `order.sync(timeout: 5.seconds).record(timeout: "payload")`
|
|
682
|
+
keeps the invocation timeout separate from the payload value.
|
|
683
|
+
|
|
661
684
|
Direct calls and `sync` use the same caller-assisted execution path. A healthy
|
|
662
685
|
actor normally needs no worker round trip, making this path suitable for HTTP
|
|
663
686
|
and MCP request/response boundaries when the handler itself fits the
|
|
@@ -677,7 +700,7 @@ recover its eventual result through the durable message identity:
|
|
|
677
700
|
|
|
678
701
|
```ruby
|
|
679
702
|
begin
|
|
680
|
-
order.
|
|
703
|
+
order.sync(timeout: 250.milliseconds).submit
|
|
681
704
|
rescue SolidObjects::SyncTimeout => error
|
|
682
705
|
result = error.message_reference.wait(
|
|
683
706
|
timeout: 5.seconds,
|
|
@@ -700,6 +723,17 @@ Actor code cannot use direct calls or `sync` on another actor; synchronous
|
|
|
700
723
|
actor-to-actor waits can deadlock in cycles. Use `async` or `send_to` and a
|
|
701
724
|
result message.
|
|
702
725
|
|
|
726
|
+
```ruby
|
|
727
|
+
send_to(
|
|
728
|
+
audit_log,
|
|
729
|
+
available_at: 5.minutes.from_now,
|
|
730
|
+
idempotency_key: event_id
|
|
731
|
+
).record(event_id:, event_name: "account_disabled")
|
|
732
|
+
```
|
|
733
|
+
|
|
734
|
+
Actor-to-actor delivery is staged with the current turn, returns `nil`, and is
|
|
735
|
+
discarded if that turn does not commit. It accepts messages, not queries.
|
|
736
|
+
|
|
703
737
|
### Domain rejection
|
|
704
738
|
|
|
705
739
|
Reject invalid input without retrying or creating a dead letter:
|
|
@@ -717,8 +751,8 @@ JSON-compatible details. The rejected message remains durable for audit, actor
|
|
|
717
751
|
state is rolled back, and no later mailbox turn is blocked.
|
|
718
752
|
|
|
719
753
|
`Rejected#code` is a `String`, even when `reject` receives a symbol. Codes must
|
|
720
|
-
match `\A[
|
|
721
|
-
|
|
754
|
+
match `\A[A-Za-z_][A-Za-z0-9_]*\z`. Invalid codes raise
|
|
755
|
+
`SolidObjects::InvalidRejectionCode` and fail the turn without retrying.
|
|
722
756
|
|
|
723
757
|
### Redelivery
|
|
724
758
|
|
|
@@ -800,11 +834,11 @@ def checkout(payment_id:, amount_cents:)
|
|
|
800
834
|
)
|
|
801
835
|
end
|
|
802
836
|
|
|
803
|
-
def payment_succeeded(effect_id:, result:)
|
|
837
|
+
def payment_succeeded(effect_id:, arguments:, result:)
|
|
804
838
|
self.checkout_status = "paid"
|
|
805
839
|
end
|
|
806
840
|
|
|
807
|
-
def payment_failed(effect_id:, error:)
|
|
841
|
+
def payment_failed(effect_id:, arguments:, error:)
|
|
808
842
|
self.checkout_status = "failed"
|
|
809
843
|
end
|
|
810
844
|
```
|
|
@@ -823,6 +857,10 @@ end
|
|
|
823
857
|
|
|
824
858
|
The provider call can repeat if a process dies after external success but
|
|
825
859
|
before recording completion. The stable effect ID is the idempotency key.
|
|
860
|
+
Success callbacks receive `effect_id:`, the originally staged `arguments:`,
|
|
861
|
+
and `result:`. Failure callbacks receive `effect_id:`, `arguments:`, and
|
|
862
|
+
`error:`, so an actor can correlate concurrent effects without storing a
|
|
863
|
+
separate callback ledger.
|
|
826
864
|
|
|
827
865
|
## Reminders
|
|
828
866
|
|
|
@@ -831,7 +869,11 @@ API. One-shot and recurring alarms are actor-owned database records:
|
|
|
831
869
|
|
|
832
870
|
```ruby
|
|
833
871
|
def schedule_evaluation
|
|
834
|
-
schedule
|
|
872
|
+
schedule(
|
|
873
|
+
at: 1.hour.from_now,
|
|
874
|
+
every: 1.hour,
|
|
875
|
+
missed: :latest
|
|
876
|
+
).evaluate(account_id:)
|
|
835
877
|
end
|
|
836
878
|
```
|
|
837
879
|
|
|
@@ -852,7 +894,7 @@ once. It also means this is a data-loss bug:
|
|
|
852
894
|
# Wrong. Every entry overwrites the previous entry's alarm.
|
|
853
895
|
def add(entry:)
|
|
854
896
|
self.entries = entries + [ entry ]
|
|
855
|
-
schedule
|
|
897
|
+
schedule(at: entry.fetch("wait_until")).deliver
|
|
856
898
|
end
|
|
857
899
|
```
|
|
858
900
|
|
|
@@ -882,7 +924,7 @@ def arm_next
|
|
|
882
924
|
earliest = entries.first
|
|
883
925
|
return unless earliest
|
|
884
926
|
|
|
885
|
-
schedule
|
|
927
|
+
schedule(at: Time.at(earliest.fetch("wait_until"))).deliver
|
|
886
928
|
end
|
|
887
929
|
```
|
|
888
930
|
|
|
@@ -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
|
|
@@ -6,5 +6,13 @@ module SolidObjects
|
|
|
6
6
|
|
|
7
7
|
belongs_to :message, class_name: "SolidObjects::Message"
|
|
8
8
|
belongs_to :instance, class_name: "SolidObjects::Instance"
|
|
9
|
+
|
|
10
|
+
# @rbs () -> bool
|
|
11
|
+
def broadcasts_value?
|
|
12
|
+
return false if observable_name == PayloadBroadcast::REVISION_OBSERVABLE
|
|
13
|
+
|
|
14
|
+
actor_class = SolidObjects.registry.fetch(instance.actor_type)
|
|
15
|
+
actor_class.definition.broadcasts_observable_value?(observable_name)
|
|
16
|
+
end
|
|
9
17
|
end
|
|
10
18
|
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
|
|
|
@@ -112,7 +113,7 @@ The supervisor starts configured worker, effect, reminder, and broadcast thread
|
|
|
112
113
|
|
|
113
114
|
### Effect worker
|
|
114
115
|
|
|
115
|
-
An effect worker claims due effect rows through the database coordination adapter, invokes a registered handler outside a database transaction, then records success or retryable failure. The handler receives the effect UUID as its idempotency key. Optional outcome messages are normal actor mailbox messages.
|
|
116
|
+
An effect worker claims due effect rows through the database coordination adapter, invokes a registered handler outside a database transaction, then records success or retryable failure. The handler receives the effect UUID as its idempotency key. Optional outcome messages are normal actor mailbox messages and receive the effect ID, originally staged arguments, and result or error.
|
|
116
117
|
|
|
117
118
|
### Reminder scheduler
|
|
118
119
|
|
|
@@ -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
|
|
@@ -167,7 +169,8 @@ turn. `SolidObjects.mutable_copy` creates an independent mutable JSON value.
|
|
|
167
169
|
`message` and `query` both execute as durable mailbox turns. A query may not
|
|
168
170
|
mutate state. The executor detects query mutation and fails the message. An
|
|
169
171
|
observable is a named projection of state used by server rendering and realtime
|
|
170
|
-
updates
|
|
172
|
+
updates. Its durable broadcast row stores the projected value by default;
|
|
173
|
+
`broadcast: :invalidation` stores only an empty invalidation marker.
|
|
171
174
|
|
|
172
175
|
Lifecycle hooks are deterministic local hooks:
|
|
173
176
|
|
|
@@ -183,7 +186,7 @@ with application Active Record writes prevented.
|
|
|
183
186
|
Enqueue uses one transaction:
|
|
184
187
|
|
|
185
188
|
1. Resolve actor class from the registry.
|
|
186
|
-
2. Validate authorization,
|
|
189
|
+
2. Validate authorization, operation, actor ID, arguments, and size.
|
|
187
190
|
3. `INSERT ... ON CONFLICT` the actor instance if missing.
|
|
188
191
|
4. Lock the instance row.
|
|
189
192
|
5. Enforce the mailbox limit.
|
|
@@ -426,7 +429,7 @@ An effect handler receives JSON arguments plus an effect context. Outcome messag
|
|
|
426
429
|
Actor code sends to another actor through a staged outbox:
|
|
427
430
|
|
|
428
431
|
```ruby
|
|
429
|
-
send_to
|
|
432
|
+
send_to(InventoryActor.ref("sku-123")).reserve(order_id: id, quantity: 2)
|
|
430
433
|
```
|
|
431
434
|
|
|
432
435
|
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 +439,7 @@ The source actor commit never waits for the target. The outbox worker allocates
|
|
|
436
439
|
A reminder record contains actor identity, a reminder name, target message, JSON arguments, next run time, optional interval, status, and occurrence counter.
|
|
437
440
|
|
|
438
441
|
```ruby
|
|
439
|
-
schedule
|
|
442
|
+
schedule(at: 30.minutes.from_now).expire
|
|
440
443
|
```
|
|
441
444
|
|
|
442
445
|
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 +470,7 @@ The expected drift categories are actors with a lost alarm, missing actors for l
|
|
|
467
470
|
|
|
468
471
|
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
472
|
|
|
470
|
-
Large repairs use `async(
|
|
473
|
+
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
474
|
|
|
472
475
|
## Realtime integration
|
|
473
476
|
|
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,15 +85,15 @@ 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
|
|
|
92
92
|
### `broadcasts`
|
|
93
93
|
|
|
94
94
|
Durable observable-change outbox. The unique message/observable key prevents
|
|
95
|
-
duplicate rows for one actor turn. Rows contain the observable JSON value
|
|
96
|
-
message/instance references used to derive invalidation metadata, never
|
|
95
|
+
duplicate rows for one actor turn. Rows contain the observable JSON value, or
|
|
96
|
+
`{}` for an invalidation-only observable, plus message/instance references used to derive invalidation metadata, never
|
|
97
97
|
personalized rendered HTML. Claim and delivery indexes support retries and
|
|
98
98
|
cleanup.
|
|
99
99
|
|
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
|
|
@@ -65,6 +65,18 @@ assert_equal "completed", message.status
|
|
|
65
65
|
Pass `roles: [:actors]` when a test intentionally wants to leave outboxes or
|
|
66
66
|
reminders pending.
|
|
67
67
|
|
|
68
|
+
Rails time travel does not move the database clock used by reminder claims. Run
|
|
69
|
+
future reminders against an explicit test instant instead of updating runtime
|
|
70
|
+
rows or sleeping:
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
assert_equal 1, run_due_reminders(now: 5.minutes.from_now)
|
|
74
|
+
assert_equal 1, drain_solid_objects(roles: [ :actors ])
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
The explicit instant controls due selection and recurring schedule advancement.
|
|
78
|
+
Claim timestamps and stale-process recovery still use database time.
|
|
79
|
+
|
|
68
80
|
`SolidObjects::TestHelper.reset_actors!` is also available for explicit suite
|
|
69
81
|
boundaries. It deletes every actor-owned row itself rather than deleting actor
|
|
70
82
|
instances and letting the database cascade remove the rest: SQLite has to be
|
|
@@ -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,
|