solid_objects 0.12.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 +15 -0
- data/README.md +23 -4
- data/app/models/solid_objects/broadcast.rb +8 -0
- data/docs/architecture.md +3 -2
- data/docs/database-schema.md +2 -2
- data/docs/development.md +12 -0
- data/docs/realtime.md +25 -3
- data/docs/roadmap.md +8 -4
- data/docs/security.md +8 -0
- data/examples/application/app/actors/shopping_cart_actor.rb +3 -3
- data/lib/solid_objects/actor.rb +6 -5
- data/lib/solid_objects/actor_channel.rb +7 -4
- data/lib/solid_objects/actor_definition.rb +15 -3
- data/lib/solid_objects/actor_view.rb +6 -1
- data/lib/solid_objects/effect_executor.rb +10 -2
- data/lib/solid_objects/errors.rb +3 -0
- data/lib/solid_objects/executor.rb +7 -1
- data/lib/solid_objects/reminder_scheduler.rb +28 -14
- data/lib/solid_objects/test_helper.rb +16 -0
- data/lib/solid_objects/turbo_stream_renderer.rb +3 -3
- data/lib/solid_objects/version.rb +1 -1
- data/sig/generated/lib/solid_objects/actor.rbs +2 -2
- data/sig/generated/lib/solid_objects/actor_definition.rbs +9 -2
- data/sig/generated/lib/solid_objects/errors.rbs +3 -0
- data/sig/generated/lib/solid_objects/reminder_scheduler.rbs +9 -6
- data/sig/generated/lib/solid_objects/test_helper.rbs +3 -0
- data/sig/generated/models/solid_objects/broadcast.rbs +2 -0
- metadata +1 -1
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,20 @@
|
|
|
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
|
+
|
|
3
18
|
## 0.12.0 - 2026-08-13
|
|
4
19
|
|
|
5
20
|
- Replace positional actor dispatch with fluent operation selection. Direct
|
data/README.md
CHANGED
|
@@ -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:
|
|
@@ -736,8 +751,8 @@ JSON-compatible details. The rejected message remains durable for audit, actor
|
|
|
736
751
|
state is rolled back, and no later mailbox turn is blocked.
|
|
737
752
|
|
|
738
753
|
`Rejected#code` is a `String`, even when `reject` receives a symbol. Codes must
|
|
739
|
-
match `\A[
|
|
740
|
-
|
|
754
|
+
match `\A[A-Za-z_][A-Za-z0-9_]*\z`. Invalid codes raise
|
|
755
|
+
`SolidObjects::InvalidRejectionCode` and fail the turn without retrying.
|
|
741
756
|
|
|
742
757
|
### Redelivery
|
|
743
758
|
|
|
@@ -819,11 +834,11 @@ def checkout(payment_id:, amount_cents:)
|
|
|
819
834
|
)
|
|
820
835
|
end
|
|
821
836
|
|
|
822
|
-
def payment_succeeded(effect_id:, result:)
|
|
837
|
+
def payment_succeeded(effect_id:, arguments:, result:)
|
|
823
838
|
self.checkout_status = "paid"
|
|
824
839
|
end
|
|
825
840
|
|
|
826
|
-
def payment_failed(effect_id:, error:)
|
|
841
|
+
def payment_failed(effect_id:, arguments:, error:)
|
|
827
842
|
self.checkout_status = "failed"
|
|
828
843
|
end
|
|
829
844
|
```
|
|
@@ -842,6 +857,10 @@ end
|
|
|
842
857
|
|
|
843
858
|
The provider call can repeat if a process dies after external success but
|
|
844
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.
|
|
845
864
|
|
|
846
865
|
## Reminders
|
|
847
866
|
|
|
@@ -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
|
data/docs/architecture.md
CHANGED
|
@@ -113,7 +113,7 @@ The supervisor starts configured worker, effect, reminder, and broadcast thread
|
|
|
113
113
|
|
|
114
114
|
### Effect worker
|
|
115
115
|
|
|
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.
|
|
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.
|
|
117
117
|
|
|
118
118
|
### Reminder scheduler
|
|
119
119
|
|
|
@@ -169,7 +169,8 @@ turn. `SolidObjects.mutable_copy` creates an independent mutable JSON value.
|
|
|
169
169
|
`message` and `query` both execute as durable mailbox turns. A query may not
|
|
170
170
|
mutate state. The executor detects query mutation and fails the message. An
|
|
171
171
|
observable is a named projection of state used by server rendering and realtime
|
|
172
|
-
updates
|
|
172
|
+
updates. Its durable broadcast row stores the projected value by default;
|
|
173
|
+
`broadcast: :invalidation` stores only an empty invalidation marker.
|
|
173
174
|
|
|
174
175
|
Lifecycle hooks are deterministic local hooks:
|
|
175
176
|
|
data/docs/database-schema.md
CHANGED
|
@@ -92,8 +92,8 @@ Status/availability/ID drives delivery; completion/ID drives cleanup.
|
|
|
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
|
@@ -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
|
data/docs/realtime.md
CHANGED
|
@@ -65,6 +65,27 @@ listed in `observes:` raises `UnknownComponentDependency`. This keeps
|
|
|
65
65
|
invalidation correct and prevents a partial from silently depending on state
|
|
66
66
|
that cannot wake it.
|
|
67
67
|
|
|
68
|
+
Observable values are shared projections. By default, each changed value is
|
|
69
|
+
stored in the broadcast outbox and can be sent as a scalar Turbo replacement to
|
|
70
|
+
every subscriber that passes `authorize_subscription`. Authorization to the
|
|
71
|
+
actor stream is not a per-viewer projection.
|
|
72
|
+
|
|
73
|
+
For a component dependency whose value must never enter the durable outbox or
|
|
74
|
+
Action Cable frame, declare it invalidation-only:
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
observable :player_one, broadcast: :invalidation do
|
|
78
|
+
player_in_seat(1)
|
|
79
|
+
end
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The runtime still compares the value around each successful turn and uses a
|
|
83
|
+
change to refresh components, but stores `{}` and renders no scalar Turbo
|
|
84
|
+
replacement. An invalidation-only observable therefore cannot be used as a
|
|
85
|
+
scalar value such as `actor.player_one`. The component endpoint reads the
|
|
86
|
+
latest committed value and authorizes it again; subscriber-specific state
|
|
87
|
+
belongs in `broadcast_payload`.
|
|
88
|
+
|
|
68
89
|
```erb
|
|
69
90
|
<ul>
|
|
70
91
|
<% actor.recent_messages.each do |message| %>
|
|
@@ -416,7 +437,8 @@ component key, locals, or DOM ID.
|
|
|
416
437
|
## Broadcast durability
|
|
417
438
|
|
|
418
439
|
The actor's fenced commit compares observables before and after the turn and
|
|
419
|
-
inserts one broadcast row per changed
|
|
440
|
+
inserts one broadcast row per changed observable. Value-broadcast observables
|
|
441
|
+
store the changed JSON value; invalidation-only observables store `{}`. The actor state, monotonic
|
|
420
442
|
`state_revision`, message completion, and broadcast rows commit atomically. A
|
|
421
443
|
rolled-back or fenced-out turn therefore cannot invalidate a component.
|
|
422
444
|
|
|
@@ -456,8 +478,8 @@ response revision fencing.
|
|
|
456
478
|
## Cost model
|
|
457
479
|
|
|
458
480
|
The durable row cost is unchanged: one broadcast row per changed observable,
|
|
459
|
-
containing its JSON value
|
|
460
|
-
invalidation metadata. No rendered document is stored. Each affected component
|
|
481
|
+
containing either its JSON value or an empty invalidation marker plus the
|
|
482
|
+
message/instance references needed to derive invalidation metadata. No rendered document is stored. Each affected component
|
|
461
483
|
adds one authorized GET and one partial render per non-coalesced state
|
|
462
484
|
revision. A repeated keyed component adds one GET and render per key. Signed
|
|
463
485
|
locals increase page and Cable subscription bytes but do not create durable
|
data/docs/roadmap.md
CHANGED
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
- Bounded activation passes, idle cache, hot-actor yield, and process records
|
|
13
13
|
- At-least-once retries, terminal domain rejection, strict poison ordering,
|
|
14
14
|
dead letters, and tail retry
|
|
15
|
-
- Transactional effects with success/failure actor messages
|
|
15
|
+
- Transactional effects with success/failure actor messages carrying the
|
|
16
|
+
originally staged arguments for callback correlation
|
|
16
17
|
- Actor-to-actor asynchronous outbox delivery. Effects and broadcasts use
|
|
17
18
|
portable status rows with polling indexes and database check constraints on
|
|
18
19
|
status, which works on all three adapters; a future version may add narrow
|
|
@@ -23,8 +24,10 @@
|
|
|
23
24
|
listed here while broken in that worker: the scheduler reached a constant the
|
|
24
25
|
caller path happened to load, so reminders never fired in production and
|
|
25
26
|
every in-process test still passed
|
|
26
|
-
- Durable observable
|
|
27
|
-
components, signed component locals, and authorized
|
|
27
|
+
- Durable value or invalidation-only observable broadcasts, scalar Turbo
|
|
28
|
+
replacement, keyed ERB components, signed component locals, and authorized
|
|
29
|
+
replace or morph refresh. Invalidation-only observables retain component
|
|
30
|
+
change detection while storing and broadcasting no projected value
|
|
28
31
|
- Batched component refreshes: components sharing a signed `batch:` collapse to
|
|
29
32
|
one browser request per revision, served as HTML frames in a JSON envelope
|
|
30
33
|
- Personalized state payload broadcasts computed per subscriber under that
|
|
@@ -45,7 +48,8 @@
|
|
|
45
48
|
- Bounded message/process pruning, actor-type opt-in instance expiration,
|
|
46
49
|
graceful caller shutdown, committed state snapshots, and an opt-in Minitest
|
|
47
50
|
helper that clears every actor-owned table itself rather than relying on the
|
|
48
|
-
database cascade, which a host application may not enforce
|
|
51
|
+
database cascade, which a host application may not enforce, and runs due
|
|
52
|
+
reminders against an explicit test time without moving the database clock
|
|
49
53
|
- Supervisor role replacement: a role whose thread dies is restarted until
|
|
50
54
|
shutdown is requested, and dead process records plus expired message and
|
|
51
55
|
process history are pruned on their own intervals without an application
|
data/docs/security.md
CHANGED
|
@@ -36,6 +36,14 @@ Opaque stream and DOM names reduce accidental disclosure but do not replace
|
|
|
36
36
|
authorization. Signed stream tokens are readable by their recipient and prove
|
|
37
37
|
integrity only.
|
|
38
38
|
|
|
39
|
+
Every normal observable value is stored in the broadcast outbox and can reach
|
|
40
|
+
every subscriber that passes `authorize_subscription` for the actor. Never put
|
|
41
|
+
credentials, session identifiers, private cards, hidden library order, or any
|
|
42
|
+
other subscriber-specific state in a value-broadcast observable. Declare a
|
|
43
|
+
component dependency with `broadcast: :invalidation` when only change metadata
|
|
44
|
+
may cross the shared stream, or use `broadcast_payload` for a projection that
|
|
45
|
+
must be computed separately for each authorized connection.
|
|
46
|
+
|
|
39
47
|
## Serialization
|
|
40
48
|
|
|
41
49
|
The built-in serializer accepts JSON-compatible data, normalizes keys to
|
|
@@ -64,14 +64,14 @@ class ShoppingCartActor < SolidObjects::Actor
|
|
|
64
64
|
)
|
|
65
65
|
end
|
|
66
66
|
|
|
67
|
-
def payment_succeeded(effect_id:, result:)
|
|
67
|
+
def payment_succeeded(effect_id:, arguments:, result:)
|
|
68
68
|
return unless checkout_status == "pending"
|
|
69
|
-
return unless
|
|
69
|
+
return unless arguments.fetch("payment_id") == payment_id
|
|
70
70
|
|
|
71
71
|
self.checkout_status = "paid"
|
|
72
72
|
end
|
|
73
73
|
|
|
74
|
-
def payment_failed(effect_id:, error:)
|
|
74
|
+
def payment_failed(effect_id:, arguments:, error:)
|
|
75
75
|
return unless checkout_status == "pending"
|
|
76
76
|
|
|
77
77
|
self.checkout_status = "payment_failed"
|
data/lib/solid_objects/actor.rb
CHANGED
|
@@ -52,9 +52,9 @@ module SolidObjects
|
|
|
52
52
|
definition.add_query(name, block)
|
|
53
53
|
end
|
|
54
54
|
|
|
55
|
-
# @rbs (Symbol | String) ?{ () -> untyped } -> ActorDefinition::Handler
|
|
56
|
-
def observable(name, &block)
|
|
57
|
-
definition.add_observable(name, block)
|
|
55
|
+
# @rbs (Symbol | String, ?broadcast: Symbol) ?{ () -> untyped } -> ActorDefinition::Handler
|
|
56
|
+
def observable(name, broadcast: :value, &block)
|
|
57
|
+
definition.add_observable(name, block, broadcast:)
|
|
58
58
|
end
|
|
59
59
|
|
|
60
60
|
# @rbs (Symbol | String) { (untyped, untyped) -> untyped } -> ActorDefinition::Handler
|
|
@@ -163,8 +163,9 @@ module SolidObjects
|
|
|
163
163
|
# @rbs (Symbol | String, String, ?details: Hash[String | Symbol, untyped]) -> bot
|
|
164
164
|
def reject(code, message, details: {})
|
|
165
165
|
rejection_code = code.to_s
|
|
166
|
-
unless rejection_code.match?(/\A[
|
|
167
|
-
raise
|
|
166
|
+
unless rejection_code.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
|
|
167
|
+
raise InvalidRejectionCode,
|
|
168
|
+
"invalid rejection code #{rejection_code.inspect}; expected a letter or underscore followed by letters, digits, or underscores"
|
|
168
169
|
end
|
|
169
170
|
|
|
170
171
|
raise Rejected.new(code: rejection_code, message:, details:)
|
|
@@ -165,13 +165,13 @@ module SolidObjects
|
|
|
165
165
|
def validate_scalar_observables!
|
|
166
166
|
return unless scalar_observables
|
|
167
167
|
|
|
168
|
-
|
|
168
|
+
definition = SolidObjects
|
|
169
169
|
.registry
|
|
170
170
|
.fetch(reference.actor_type)
|
|
171
171
|
.definition
|
|
172
|
-
.observables
|
|
173
172
|
unknown = scalar_observables.find do |name|
|
|
174
|
-
!observables.key?(name.to_sym)
|
|
173
|
+
!definition.observables.key?(name.to_sym) ||
|
|
174
|
+
!definition.broadcasts_observable_value?(name)
|
|
175
175
|
end
|
|
176
176
|
return unless unknown
|
|
177
177
|
|
|
@@ -182,7 +182,10 @@ module SolidObjects
|
|
|
182
182
|
def scalar_observable_names(snapshot)
|
|
183
183
|
return scalar_observables if scalar_observables
|
|
184
184
|
|
|
185
|
-
snapshot.actor_class.definition
|
|
185
|
+
definition = snapshot.actor_class.definition
|
|
186
|
+
definition.observables.keys.filter_map do |name|
|
|
187
|
+
name.to_s if definition.broadcasts_observable_value?(name)
|
|
188
|
+
end
|
|
186
189
|
end
|
|
187
190
|
end
|
|
188
191
|
end
|
|
@@ -9,6 +9,7 @@ module SolidObjects
|
|
|
9
9
|
# @rbs @messages: Hash[Symbol, Handler]
|
|
10
10
|
# @rbs @queries: Hash[Symbol, Handler]
|
|
11
11
|
# @rbs @observables: Hash[Symbol, Handler]
|
|
12
|
+
# @rbs @observable_broadcasts: Hash[Symbol, Symbol]
|
|
12
13
|
# @rbs @payload_broadcasts: Hash[Symbol, Handler]
|
|
13
14
|
# @rbs @state_version: Integer
|
|
14
15
|
# @rbs @state_migrations: Array[StateMigration]
|
|
@@ -33,6 +34,7 @@ module SolidObjects
|
|
|
33
34
|
@messages = {}
|
|
34
35
|
@queries = {}
|
|
35
36
|
@observables = {}
|
|
37
|
+
@observable_broadcasts = {}
|
|
36
38
|
@payload_broadcasts = {}
|
|
37
39
|
@state_version = 1
|
|
38
40
|
@state_migrations = []
|
|
@@ -76,17 +78,26 @@ module SolidObjects
|
|
|
76
78
|
add_handler(collection: queries, name:, block:)
|
|
77
79
|
end
|
|
78
80
|
|
|
79
|
-
# @rbs (Symbol | String, Proc
|
|
80
|
-
def add_observable(name, block = nil)
|
|
81
|
+
# @rbs (Symbol | String, Proc?, broadcast: Symbol) -> Handler
|
|
82
|
+
def add_observable(name, block = nil, broadcast:)
|
|
81
83
|
observable_name = name.to_sym
|
|
82
84
|
raise InvalidActor, "#{observable_name.inspect} observable is already defined" if observables.key?(observable_name)
|
|
85
|
+
unless %i[value invalidation].include?(broadcast)
|
|
86
|
+
raise InvalidActor, "observable broadcast must be :value or :invalidation"
|
|
87
|
+
end
|
|
83
88
|
|
|
84
89
|
observable_block = block || -> { state.fetch(observable_name) }
|
|
85
90
|
Handler.new(name: observable_name, block: observable_block).tap do |handler|
|
|
86
91
|
observables[observable_name] = handler
|
|
92
|
+
observable_broadcasts[observable_name] = broadcast
|
|
87
93
|
end
|
|
88
94
|
end
|
|
89
95
|
|
|
96
|
+
# @rbs (Symbol | String) -> bool
|
|
97
|
+
def broadcasts_observable_value?(name)
|
|
98
|
+
observable_broadcasts.fetch(name.to_sym) == :value
|
|
99
|
+
end
|
|
100
|
+
|
|
90
101
|
# @rbs (Symbol | String, Proc) -> Handler
|
|
91
102
|
def add_payload_broadcast(name, block)
|
|
92
103
|
payload_name = name.to_sym
|
|
@@ -168,6 +179,7 @@ module SolidObjects
|
|
|
168
179
|
copy.instance_variable_set(:@messages, messages.dup)
|
|
169
180
|
copy.instance_variable_set(:@queries, queries.dup)
|
|
170
181
|
copy.instance_variable_set(:@observables, observables.dup)
|
|
182
|
+
copy.instance_variable_set(:@observable_broadcasts, observable_broadcasts.dup)
|
|
171
183
|
copy.instance_variable_set(:@payload_broadcasts, payload_broadcasts.dup)
|
|
172
184
|
copy.instance_variable_set(:@state_version, state_version)
|
|
173
185
|
copy.instance_variable_set(:@state_migrations, state_migrations.dup)
|
|
@@ -180,7 +192,7 @@ module SolidObjects
|
|
|
180
192
|
|
|
181
193
|
private
|
|
182
194
|
|
|
183
|
-
attr_reader :attribute_queries, :method_messages
|
|
195
|
+
attr_reader :attribute_queries, :method_messages, :observable_broadcasts
|
|
184
196
|
|
|
185
197
|
# @rbs (Symbol) -> Handler
|
|
186
198
|
def add_method_message(name)
|
|
@@ -24,8 +24,13 @@ module SolidObjects
|
|
|
24
24
|
# @rbs (Symbol | String) -> untyped
|
|
25
25
|
def value(name)
|
|
26
26
|
observable_name = name.to_sym
|
|
27
|
-
|
|
27
|
+
definition = snapshot.actor_class.definition
|
|
28
|
+
handler = definition.observables[observable_name]
|
|
28
29
|
raise UnknownMessage, "unknown observable #{name.inspect}" unless handler
|
|
30
|
+
unless definition.broadcasts_observable_value?(observable_name)
|
|
31
|
+
raise ArgumentError,
|
|
32
|
+
"invalidation-only observable #{observable_name.inspect} cannot render as a scalar target"
|
|
33
|
+
end
|
|
29
34
|
|
|
30
35
|
authorize_read!(observable_name)
|
|
31
36
|
value = snapshot.observable_value(observable_name)
|
|
@@ -153,7 +153,11 @@ module SolidObjects
|
|
|
153
153
|
effect: locked_effect,
|
|
154
154
|
operation: locked_effect.success_operation,
|
|
155
155
|
outcome: "success",
|
|
156
|
-
arguments: {
|
|
156
|
+
arguments: {
|
|
157
|
+
"effect_id" => locked_effect.effect_id,
|
|
158
|
+
"arguments" => locked_effect.arguments,
|
|
159
|
+
"result" => serialized_result
|
|
160
|
+
}
|
|
157
161
|
)
|
|
158
162
|
locked_effect.update!(
|
|
159
163
|
status: "completed",
|
|
@@ -191,7 +195,11 @@ module SolidObjects
|
|
|
191
195
|
effect: locked_effect,
|
|
192
196
|
operation: locked_effect.failure_operation,
|
|
193
197
|
outcome: "failure",
|
|
194
|
-
arguments: {
|
|
198
|
+
arguments: {
|
|
199
|
+
"effect_id" => locked_effect.effect_id,
|
|
200
|
+
"arguments" => locked_effect.arguments,
|
|
201
|
+
"error" => error_details
|
|
202
|
+
}
|
|
195
203
|
)
|
|
196
204
|
end
|
|
197
205
|
locked_effect.update!(
|
data/lib/solid_objects/errors.rb
CHANGED
|
@@ -301,12 +301,18 @@ module SolidObjects
|
|
|
301
301
|
end
|
|
302
302
|
|
|
303
303
|
broadcasts.each do |observable_name, value|
|
|
304
|
+
stored_value = if observable_name != PayloadBroadcast::REVISION_OBSERVABLE &&
|
|
305
|
+
actor.class.definition.broadcasts_observable_value?(observable_name)
|
|
306
|
+
value
|
|
307
|
+
else
|
|
308
|
+
{}
|
|
309
|
+
end
|
|
304
310
|
Broadcast.create!(
|
|
305
311
|
message:,
|
|
306
312
|
instance:,
|
|
307
313
|
broadcast_id: SecureRandom.uuid,
|
|
308
314
|
observable_name:,
|
|
309
|
-
value
|
|
315
|
+
value: stored_value,
|
|
310
316
|
state_version: actor.class.state_version,
|
|
311
317
|
activation_generation: activation.lease.generation,
|
|
312
318
|
status: "pending",
|
|
@@ -19,15 +19,16 @@ module SolidObjects
|
|
|
19
19
|
@shutdown_requested = false
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
-
# @rbs () -> bool
|
|
23
|
-
def run_once
|
|
22
|
+
# @rbs (?now: Time?) -> bool
|
|
23
|
+
def run_once(now: nil)
|
|
24
24
|
return false if stopped?
|
|
25
25
|
|
|
26
|
+
now = normalize_test_time(now)
|
|
26
27
|
process_registry.heartbeat
|
|
27
|
-
reminder = claim_next
|
|
28
|
+
reminder = claim_next(now:)
|
|
28
29
|
return false unless reminder
|
|
29
30
|
|
|
30
|
-
enqueue(reminder).present?
|
|
31
|
+
enqueue(reminder, now:).present?
|
|
31
32
|
rescue
|
|
32
33
|
release(reminder) if reminder
|
|
33
34
|
raise
|
|
@@ -74,13 +75,14 @@ module SolidObjects
|
|
|
74
75
|
|
|
75
76
|
attr_reader :process_registry, :database_adapter
|
|
76
77
|
|
|
77
|
-
# @rbs () -> Reminder?
|
|
78
|
-
def claim_next
|
|
78
|
+
# @rbs (now: Time?) -> Reminder?
|
|
79
|
+
def claim_next(now:)
|
|
79
80
|
database_adapter.transaction do
|
|
80
|
-
|
|
81
|
-
|
|
81
|
+
database_now = database_adapter.database_now
|
|
82
|
+
due_at = now || database_now
|
|
83
|
+
stale_at = database_now - SolidObjects.configuration.process_alive_threshold
|
|
82
84
|
relation = Reminder
|
|
83
|
-
.where(status: "scheduled", next_run_at: ..
|
|
85
|
+
.where(status: "scheduled", next_run_at: ..due_at)
|
|
84
86
|
.where("claimed_by IS NULL OR claimed_at <= ?", stale_at)
|
|
85
87
|
.order(:next_run_at, :id)
|
|
86
88
|
reminder = database_adapter.lock_candidates(relation).first
|
|
@@ -88,14 +90,14 @@ module SolidObjects
|
|
|
88
90
|
|
|
89
91
|
reminder.update!(
|
|
90
92
|
claimed_by: process_registry.process_record.id,
|
|
91
|
-
claimed_at:
|
|
93
|
+
claimed_at: database_now
|
|
92
94
|
)
|
|
93
95
|
reminder
|
|
94
96
|
end
|
|
95
97
|
end
|
|
96
98
|
|
|
97
|
-
# @rbs (Reminder) -> MessageReference?
|
|
98
|
-
def enqueue(reminder)
|
|
99
|
+
# @rbs (Reminder, now: Time?) -> MessageReference?
|
|
100
|
+
def enqueue(reminder, now:)
|
|
99
101
|
actor_class = SolidObjects.registry.fetch(reminder.actor_type)
|
|
100
102
|
unless actor_class.definition.messages.key?(reminder.operation.to_sym)
|
|
101
103
|
raise UnknownMessage, "unknown reminder operation #{reminder.operation.inspect}"
|
|
@@ -109,7 +111,7 @@ module SolidObjects
|
|
|
109
111
|
locked_reminder = Reminder.lock.find_by(id: reminder.id)
|
|
110
112
|
next unless locked_reminder
|
|
111
113
|
verify_claim!(locked_reminder)
|
|
112
|
-
|
|
114
|
+
schedule_now = now || database_adapter.database_now
|
|
113
115
|
message = mailbox.enqueue_in_transaction(
|
|
114
116
|
reference: Reference.new(actor_type: instance.actor_type, actor_id: instance.actor_id),
|
|
115
117
|
operation: locked_reminder.operation,
|
|
@@ -122,7 +124,7 @@ module SolidObjects
|
|
|
122
124
|
locked_reminder.update!(
|
|
123
125
|
status: recurring ? "scheduled" : "completed",
|
|
124
126
|
occurrence: locked_reminder.occurrence + 1,
|
|
125
|
-
next_run_at: recurring ? next_run_at(locked_reminder,
|
|
127
|
+
next_run_at: recurring ? next_run_at(locked_reminder, schedule_now) : locked_reminder.next_run_at,
|
|
126
128
|
claimed_by: nil,
|
|
127
129
|
claimed_at: nil
|
|
128
130
|
)
|
|
@@ -164,5 +166,17 @@ module SolidObjects
|
|
|
164
166
|
missed_intervals = ((now - next_run) / interval).floor + 1
|
|
165
167
|
next_run + (missed_intervals * interval)
|
|
166
168
|
end
|
|
169
|
+
|
|
170
|
+
# @rbs (Time?) -> Time?
|
|
171
|
+
def normalize_test_time(now)
|
|
172
|
+
return unless now
|
|
173
|
+
|
|
174
|
+
time = now.to_time
|
|
175
|
+
return time if time.to_f.finite?
|
|
176
|
+
|
|
177
|
+
raise ArgumentError
|
|
178
|
+
rescue ArgumentError, NoMethodError, RangeError
|
|
179
|
+
raise ArgumentError, "reminder test time must be a valid time"
|
|
180
|
+
end
|
|
167
181
|
end
|
|
168
182
|
end
|
|
@@ -68,6 +68,22 @@ module SolidObjects
|
|
|
68
68
|
runners&.uniq&.each(&:stop)
|
|
69
69
|
end
|
|
70
70
|
|
|
71
|
+
# @rbs (now: Time, ?max_reminders: Integer) -> Integer
|
|
72
|
+
def run_due_reminders(now:, max_reminders: 10_000)
|
|
73
|
+
unless max_reminders.is_a?(Integer) && max_reminders.positive?
|
|
74
|
+
raise ArgumentError, "max_reminders must be a positive integer"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
scheduler = ReminderScheduler.new
|
|
78
|
+
processed = 0
|
|
79
|
+
while processed < max_reminders && scheduler.run_once(now:)
|
|
80
|
+
processed += 1
|
|
81
|
+
end
|
|
82
|
+
processed
|
|
83
|
+
ensure
|
|
84
|
+
scheduler&.stop
|
|
85
|
+
end
|
|
86
|
+
|
|
71
87
|
private
|
|
72
88
|
|
|
73
89
|
# @rbs (Array[Symbol]) -> Array[Worker | EffectExecutor | ReminderScheduler | BroadcastExecutor]
|
|
@@ -16,14 +16,14 @@ module SolidObjects
|
|
|
16
16
|
actor_type: broadcast.instance.actor_type,
|
|
17
17
|
actor_id: broadcast.instance.actor_id
|
|
18
18
|
)
|
|
19
|
-
stream = if broadcast.
|
|
20
|
-
""
|
|
21
|
-
else
|
|
19
|
+
stream = if broadcast.broadcasts_value?
|
|
22
20
|
observable_value(
|
|
23
21
|
reference:,
|
|
24
22
|
name: broadcast.observable_name,
|
|
25
23
|
value: broadcast.value
|
|
26
24
|
)
|
|
25
|
+
else
|
|
26
|
+
""
|
|
27
27
|
end
|
|
28
28
|
metadata = Base64.urlsafe_encode64(
|
|
29
29
|
JSON.generate(
|
|
@@ -87,8 +87,8 @@ module SolidObjects
|
|
|
87
87
|
# @rbs (Symbol | String) { (*untyped, **untyped) -> untyped } -> ActorDefinition::Handler
|
|
88
88
|
def self.query: (Symbol | String) { (*untyped, **untyped) -> untyped } -> ActorDefinition::Handler
|
|
89
89
|
|
|
90
|
-
# @rbs (Symbol | String) ?{ () -> untyped } -> ActorDefinition::Handler
|
|
91
|
-
def self.observable: (Symbol | String) ?{ () -> untyped } -> ActorDefinition::Handler
|
|
90
|
+
# @rbs (Symbol | String, ?broadcast: Symbol) ?{ () -> untyped } -> ActorDefinition::Handler
|
|
91
|
+
def self.observable: (Symbol | String, ?broadcast: Symbol) ?{ () -> untyped } -> ActorDefinition::Handler
|
|
92
92
|
|
|
93
93
|
# @rbs (Symbol | String) { (untyped, untyped) -> untyped } -> ActorDefinition::Handler
|
|
94
94
|
def self.broadcast_payload: (Symbol | String) { (untyped, untyped) -> untyped } -> ActorDefinition::Handler
|
|
@@ -44,6 +44,8 @@ module SolidObjects
|
|
|
44
44
|
|
|
45
45
|
@payload_broadcasts: Hash[Symbol, Handler]
|
|
46
46
|
|
|
47
|
+
@observable_broadcasts: Hash[Symbol, Symbol]
|
|
48
|
+
|
|
47
49
|
@observables: Hash[Symbol, Handler]
|
|
48
50
|
|
|
49
51
|
@queries: Hash[Symbol, Handler]
|
|
@@ -82,8 +84,11 @@ module SolidObjects
|
|
|
82
84
|
# @rbs (Symbol | String, Proc) -> Handler
|
|
83
85
|
def add_query: (Symbol | String, Proc) -> Handler
|
|
84
86
|
|
|
85
|
-
# @rbs (Symbol | String, Proc
|
|
86
|
-
def add_observable: (Symbol | String, Proc
|
|
87
|
+
# @rbs (Symbol | String, Proc?, broadcast: Symbol) -> Handler
|
|
88
|
+
def add_observable: (Symbol | String, Proc?, broadcast: Symbol) -> Handler
|
|
89
|
+
|
|
90
|
+
# @rbs (Symbol | String) -> bool
|
|
91
|
+
def broadcasts_observable_value?: (Symbol | String) -> bool
|
|
87
92
|
|
|
88
93
|
# @rbs (Symbol | String, Proc) -> Handler
|
|
89
94
|
def add_payload_broadcast: (Symbol | String, Proc) -> Handler
|
|
@@ -109,6 +114,8 @@ module SolidObjects
|
|
|
109
114
|
|
|
110
115
|
attr_reader method_messages: untyped
|
|
111
116
|
|
|
117
|
+
attr_reader observable_broadcasts: untyped
|
|
118
|
+
|
|
112
119
|
# @rbs (Symbol) -> Handler
|
|
113
120
|
def add_method_message: (Symbol) -> Handler
|
|
114
121
|
|
|
@@ -13,8 +13,8 @@ module SolidObjects
|
|
|
13
13
|
# @rbs (?process_registry: ProcessRegistry, ?database_adapter: DatabaseAdapter) -> void
|
|
14
14
|
def initialize: (?process_registry: ProcessRegistry, ?database_adapter: DatabaseAdapter) -> void
|
|
15
15
|
|
|
16
|
-
# @rbs () -> bool
|
|
17
|
-
def run_once: () -> bool
|
|
16
|
+
# @rbs (?now: Time?) -> bool
|
|
17
|
+
def run_once: (?now: Time?) -> bool
|
|
18
18
|
|
|
19
19
|
# @rbs () -> void
|
|
20
20
|
def stop: () -> void
|
|
@@ -37,11 +37,11 @@ module SolidObjects
|
|
|
37
37
|
|
|
38
38
|
attr_reader database_adapter: untyped
|
|
39
39
|
|
|
40
|
-
# @rbs () -> Reminder?
|
|
41
|
-
def claim_next: () -> Reminder?
|
|
40
|
+
# @rbs (now: Time?) -> Reminder?
|
|
41
|
+
def claim_next: (now: Time?) -> Reminder?
|
|
42
42
|
|
|
43
|
-
# @rbs (Reminder) -> MessageReference?
|
|
44
|
-
def enqueue: (Reminder) -> MessageReference?
|
|
43
|
+
# @rbs (Reminder, now: Time?) -> MessageReference?
|
|
44
|
+
def enqueue: (Reminder, now: Time?) -> MessageReference?
|
|
45
45
|
|
|
46
46
|
# @rbs (Reminder) -> void
|
|
47
47
|
def release: (Reminder) -> void
|
|
@@ -51,5 +51,8 @@ module SolidObjects
|
|
|
51
51
|
|
|
52
52
|
# @rbs (Reminder, Time) -> Time
|
|
53
53
|
def next_run_at: (Reminder, Time) -> Time
|
|
54
|
+
|
|
55
|
+
# @rbs (Time?) -> Time?
|
|
56
|
+
def normalize_test_time: (Time?) -> Time?
|
|
54
57
|
end
|
|
55
58
|
end
|
|
@@ -26,6 +26,9 @@ module SolidObjects
|
|
|
26
26
|
# @rbs (?roles: Array[Symbol], ?max_passes: Integer) -> Integer
|
|
27
27
|
def drain_solid_objects: (?roles: Array[Symbol], ?max_passes: Integer) -> Integer
|
|
28
28
|
|
|
29
|
+
# @rbs (now: Time, ?max_reminders: Integer) -> Integer
|
|
30
|
+
def run_due_reminders: (now: Time, ?max_reminders: Integer) -> Integer
|
|
31
|
+
|
|
29
32
|
private
|
|
30
33
|
|
|
31
34
|
# @rbs (Array[Symbol]) -> Array[Worker | EffectExecutor | ReminderScheduler | BroadcastExecutor]
|