solid_objects 0.10.2 → 0.10.3
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 +26 -0
- data/README.md +56 -0
- data/docs/architecture.md +2 -0
- data/docs/database-schema.md +2 -1
- data/docs/development.md +7 -1
- data/docs/operations.md +9 -0
- data/docs/roadmap.md +2 -1
- data/lib/solid_objects/executor.rb +40 -3
- data/lib/solid_objects/test_helper.rb +24 -1
- data/lib/solid_objects/version.rb +1 -1
- data/sig/generated/lib/solid_objects/executor.rbs +16 -2
- data/sig/generated/lib/solid_objects/test_helper.rbs +12 -0
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 36d165ff933a151ee0cc9fbff1ae834333f9591d1ae71068a0127d198dc4d5d8
|
|
4
|
+
data.tar.gz: 1cfd279335dcf34f4cd2dd659412e04c5ac8d6ed98e1815cbd184bb38f67585f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9328e60fa9f4e08f17cfe4404fe117af3a2c8efef53ba379b5c824d73a71063ae7f3b8e438b177ec4fcacb1ca31fdb86686212a6ac6d0f972e4f337233d6dea2
|
|
7
|
+
data.tar.gz: b39f02c65284721d06c02eee9bbd678eb808770136847715f83525e3e43fe461f89e9837c0811c6730b50584b4c74c572dcf8e3de1cbbdae7fcfbf2dbc7f6708
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.10.3 - 2026-08-11
|
|
4
|
+
|
|
5
|
+
- Delete every actor-owned row in `SolidObjects::TestHelper#reset_actors!`. It
|
|
6
|
+
deleted actor instances and processes and left the other seven tables to the
|
|
7
|
+
database cascade. That cascade is not enforced everywhere: SQLite has to be
|
|
8
|
+
asked for foreign keys, MySQL has to be on InnoDB, and a host application may
|
|
9
|
+
have stripped the constraints out of the copied migration. Where it does not
|
|
10
|
+
fire, messages, ready and claimed mailbox rows, reminders, effects,
|
|
11
|
+
broadcasts, and dead letters all survived into the next test with an
|
|
12
|
+
`instance_id` pointing at nothing, so a test reading any of them saw another
|
|
13
|
+
test's rows and failed depending on order. Reported as reminders leaking,
|
|
14
|
+
which is where it surfaces first because reminders outlive the message that
|
|
15
|
+
created them.
|
|
16
|
+
- Document that a reminder is one named alarm per actor. `schedule` is keyed by
|
|
17
|
+
actor and reminder name, so scheduling a name that is already armed moves
|
|
18
|
+
that alarm rather than adding a second. The behaviour is deliberate and
|
|
19
|
+
matches Orleans and Durable Objects, but it was stated nowhere: an actor that
|
|
20
|
+
armed one reminder per queued item silently kept only the last, and the
|
|
21
|
+
earlier wake-ups never happened. The reminders guide now states the
|
|
22
|
+
uniqueness key and shows the one-alarm-many-items pattern to use instead.
|
|
23
|
+
- Add `solid_objects.reminder.replaced`, reported when a `schedule` call moves
|
|
24
|
+
an alarm already armed under the same name to a different time. It carries
|
|
25
|
+
the actor identity, reminder name, previous run time, and next run time, and
|
|
26
|
+
no arguments. Rescheduling to the same time reports nothing. The replacement
|
|
27
|
+
was previously indistinguishable from a first schedule.
|
|
28
|
+
|
|
3
29
|
## 0.10.2 - 2026-08-10
|
|
4
30
|
|
|
5
31
|
- Load the mailbox when the gem is required. `SolidObjects::Mailbox` was
|
data/README.md
CHANGED
|
@@ -805,6 +805,62 @@ end
|
|
|
805
805
|
Use `missed: :latest` to coalesce missed occurrences or `missed: :all` to
|
|
806
806
|
enqueue each one.
|
|
807
807
|
|
|
808
|
+
### A reminder is one named alarm per actor
|
|
809
|
+
|
|
810
|
+
The uniqueness key is `(actor, reminder name)`. Scheduling a name that is
|
|
811
|
+
already armed **moves the existing alarm** rather than adding a second one. The
|
|
812
|
+
database enforces this with a unique index on `(instance_id, name)`.
|
|
813
|
+
|
|
814
|
+
This is the same model as Orleans reminders and Durable Objects alarms, and it
|
|
815
|
+
is what makes a reminder safe to re-arm from a handler that may run more than
|
|
816
|
+
once. It also means this is a data-loss bug:
|
|
817
|
+
|
|
818
|
+
```ruby
|
|
819
|
+
# Wrong. Every entry overwrites the previous entry's alarm.
|
|
820
|
+
def add(entry:)
|
|
821
|
+
self.entries = entries + [ entry ]
|
|
822
|
+
schedule :deliver, at: entry.fetch("wait_until"), arguments: {}
|
|
823
|
+
end
|
|
824
|
+
```
|
|
825
|
+
|
|
826
|
+
Two entries leave one reminder. The earlier wake-up never happens, nothing
|
|
827
|
+
raises, and nothing is logged except a `solid_objects.reminder.replaced` event.
|
|
828
|
+
|
|
829
|
+
Arm one alarm for the earliest item instead, and let the handler drain
|
|
830
|
+
everything now due before arming the next:
|
|
831
|
+
|
|
832
|
+
```ruby
|
|
833
|
+
def add(entry:)
|
|
834
|
+
self.entries = (entries + [ entry ]).sort_by { |item| item.fetch("wait_until") }
|
|
835
|
+
arm_next
|
|
836
|
+
end
|
|
837
|
+
|
|
838
|
+
def deliver
|
|
839
|
+
now = Time.current.to_i
|
|
840
|
+
due, pending = entries.partition { |item| item.fetch("wait_until") <= now }
|
|
841
|
+
due.each { |item| emit :send_push, **item.symbolize_keys }
|
|
842
|
+
self.entries = pending
|
|
843
|
+
arm_next
|
|
844
|
+
end
|
|
845
|
+
|
|
846
|
+
private
|
|
847
|
+
|
|
848
|
+
def arm_next
|
|
849
|
+
earliest = entries.first
|
|
850
|
+
return unless earliest
|
|
851
|
+
|
|
852
|
+
schedule :deliver, at: Time.at(earliest.fetch("wait_until")), arguments: {}
|
|
853
|
+
end
|
|
854
|
+
```
|
|
855
|
+
|
|
856
|
+
`deliver` drains every due item rather than one, so a single alarm serves a
|
|
857
|
+
whole queue and a missed or coalesced occurrence cannot strand an entry. Use a
|
|
858
|
+
distinct reminder name only when you genuinely need two independent alarms on
|
|
859
|
+
one actor, such as `:deliver` and `:sweep`.
|
|
860
|
+
|
|
861
|
+
Solid Objects has no `unschedule`. A reminder stops when its handler does not
|
|
862
|
+
re-arm it, and destroying an actor removes its reminders.
|
|
863
|
+
|
|
808
864
|
Self-scheduling actors should also have a low-frequency application reconciler.
|
|
809
865
|
It may read `SolidObjects::Instance.states_for`, `.without_pending_work`, and
|
|
810
866
|
`.orphaned`, but every repair must go through `async`. Never bulk-update actor
|
data/docs/architecture.md
CHANGED
|
@@ -439,6 +439,8 @@ A reminder record contains actor identity, a reminder name, target message, JSON
|
|
|
439
439
|
schedule :expire, at: 30.minutes.from_now, arguments: {}
|
|
440
440
|
```
|
|
441
441
|
|
|
442
|
+
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.
|
|
443
|
+
|
|
442
444
|
When due, the scheduler locks the source instance and creates a normal mailbox
|
|
443
445
|
row with an idempotency key derived from reminder ID and occurrence. The
|
|
444
446
|
mailbox insert and reminder advancement commit atomically. The mailbox provides
|
data/docs/database-schema.md
CHANGED
|
@@ -77,7 +77,8 @@ Indexes:
|
|
|
77
77
|
### `reminders`
|
|
78
78
|
|
|
79
79
|
Durable one-shot or recurring alarm definitions. Unique instance/name makes
|
|
80
|
-
rescheduling an actor-owned reminder deterministic
|
|
80
|
+
rescheduling an actor-owned reminder deterministic, which also means a second
|
|
81
|
+
`schedule` under the same name moves that alarm rather than adding one. Status/next-run/ID drives
|
|
81
82
|
the due scan. Claim ownership references the process registry, and constraints
|
|
82
83
|
limit recurrence intervals, missed-work policies, and statuses.
|
|
83
84
|
|
data/docs/development.md
CHANGED
|
@@ -66,7 +66,13 @@ Pass `roles: [:actors]` when a test intentionally wants to leave outboxes or
|
|
|
66
66
|
reminders pending.
|
|
67
67
|
|
|
68
68
|
`SolidObjects::TestHelper.reset_actors!` is also available for explicit suite
|
|
69
|
-
boundaries.
|
|
69
|
+
boundaries. It deletes every actor-owned row itself rather than deleting actor
|
|
70
|
+
instances and letting the database cascade remove the rest: SQLite has to be
|
|
71
|
+
asked for foreign keys, MySQL has to be on InnoDB, and a host application may
|
|
72
|
+
have stripped the constraints out of the copied migration. Where the cascade
|
|
73
|
+
does not fire, a row that survives a reset carries an `instance_id` pointing at
|
|
74
|
+
nothing, and the next test that reads reminders or dead letters sees another
|
|
75
|
+
test's data.
|
|
70
76
|
|
|
71
77
|
## Inline RBS
|
|
72
78
|
|
data/docs/operations.md
CHANGED
|
@@ -155,6 +155,15 @@ transaction rejection, commit-action start/completion/failure, effect and
|
|
|
155
155
|
broadcast enqueue/completion, reminder enqueue, actor destruction/expiration,
|
|
156
156
|
retention pruning, process cleanup, and supervisor lifecycle.
|
|
157
157
|
|
|
158
|
+
`solid_objects.reminder.replaced` reports a `schedule` call that moved an alarm
|
|
159
|
+
already armed under the same name on the same actor, carrying the actor
|
|
160
|
+
identity, reminder `name`, `previous_run_at`, and `next_run_at`. Reminders are
|
|
161
|
+
keyed by actor and name, so re-arming a name is an update rather than a second
|
|
162
|
+
alarm. That is deliberate, and it is silent: an actor that arms one reminder
|
|
163
|
+
per queued item keeps only the last, and the earlier wake-up never happens.
|
|
164
|
+
Watch this event if your actors schedule from a loop or from a handler that can
|
|
165
|
+
run more than once. Rescheduling to the same time reports nothing.
|
|
166
|
+
|
|
158
167
|
`solid_objects.component.refreshed` covers every authorized component refresh
|
|
159
168
|
request. Its payload carries the actor identity, `component_name`,
|
|
160
169
|
`component_key`, declared `dependencies`, `refresh_method`, the rendered
|
data/docs/roadmap.md
CHANGED
|
@@ -44,7 +44,8 @@
|
|
|
44
44
|
result recovery
|
|
45
45
|
- Bounded message/process pruning, actor-type opt-in instance expiration,
|
|
46
46
|
graceful caller shutdown, committed state snapshots, and an opt-in Minitest
|
|
47
|
-
helper
|
|
47
|
+
helper that clears every actor-owned table itself rather than relying on the
|
|
48
|
+
database cascade, which a host application may not enforce
|
|
48
49
|
- Supervisor role replacement: a role whose thread dies is restarted until
|
|
49
50
|
shutdown is requested, and dead process records plus expired message and
|
|
50
51
|
process history are pruned on their own intervals without an application
|
|
@@ -90,8 +90,14 @@ module SolidObjects
|
|
|
90
90
|
reminder_intents = actor.drain_reminder_intents
|
|
91
91
|
outbound_message_intents = actor.drain_outbound_message_intents
|
|
92
92
|
enqueued_effects = []
|
|
93
|
+
moved_reminders = []
|
|
93
94
|
|
|
94
95
|
activation.lease.fenced_transaction do |instance|
|
|
96
|
+
# A busy database makes the adapter retry this whole block, so an
|
|
97
|
+
# attempt that was rolled back must not leave its work in the lists the
|
|
98
|
+
# reporting below reads. Each attempt starts from empty.
|
|
99
|
+
enqueued_effects.clear
|
|
100
|
+
moved_reminders.clear
|
|
95
101
|
claimed_message = matching_claim!
|
|
96
102
|
locked_message = Message.lock.find(message.id)
|
|
97
103
|
execute_commit_actions(commit_action_intents)
|
|
@@ -107,7 +113,7 @@ module SolidObjects
|
|
|
107
113
|
completed_at: SolidObjects.database_adapter.database_now
|
|
108
114
|
)
|
|
109
115
|
enqueued_effects.concat(enqueue_effects(locked_message, instance, effect_intents))
|
|
110
|
-
schedule_reminders(instance, reminder_intents)
|
|
116
|
+
moved_reminders.concat(schedule_reminders(instance, reminder_intents))
|
|
111
117
|
enqueued_effects.concat(
|
|
112
118
|
enqueue_actor_messages(locked_message, instance, outbound_message_intents)
|
|
113
119
|
)
|
|
@@ -127,6 +133,9 @@ module SolidObjects
|
|
|
127
133
|
observable_name:
|
|
128
134
|
)
|
|
129
135
|
end
|
|
136
|
+
moved_reminders.each do |moved|
|
|
137
|
+
SolidObjects.instrument(:"reminder.replaced", **moved)
|
|
138
|
+
end
|
|
130
139
|
enqueued_effects.each do |effect|
|
|
131
140
|
SolidObjects.instrument(
|
|
132
141
|
:"effect.enqueued",
|
|
@@ -211,10 +220,20 @@ module SolidObjects
|
|
|
211
220
|
end
|
|
212
221
|
end
|
|
213
222
|
|
|
214
|
-
#
|
|
223
|
+
# A reminder is one named alarm per actor, so scheduling a name that is
|
|
224
|
+
# already armed moves it rather than adding a second. An actor that arms a
|
|
225
|
+
# reminder per queued item therefore keeps only the last, and nothing else
|
|
226
|
+
# about that is visible: the write succeeds and the earlier wake-up simply
|
|
227
|
+
# never happens.
|
|
228
|
+
# Moves are returned rather than reported here, so the report happens after
|
|
229
|
+
# the turn commits. A rolled back turn would otherwise announce an alarm
|
|
230
|
+
# that never moved, which is the opposite of the visibility this event
|
|
231
|
+
# exists to provide.
|
|
232
|
+
# @rbs (Instance, Array[Actor::ReminderIntent]) -> Array[Hash[Symbol, untyped]]
|
|
215
233
|
def schedule_reminders(instance, intents)
|
|
216
|
-
intents.
|
|
234
|
+
intents.filter_map do |intent|
|
|
217
235
|
reminder = Reminder.find_or_initialize_by(instance:, name: intent.name)
|
|
236
|
+
previous_run_at = reminder.next_run_at
|
|
218
237
|
reminder.assign_attributes(
|
|
219
238
|
actor_type: instance.actor_type,
|
|
220
239
|
actor_id: instance.actor_id,
|
|
@@ -227,10 +246,28 @@ module SolidObjects
|
|
|
227
246
|
claimed_by: nil,
|
|
228
247
|
claimed_at: nil
|
|
229
248
|
)
|
|
249
|
+
moved = moved_reminder_payload(reminder, previous_run_at)
|
|
230
250
|
reminder.save!
|
|
251
|
+
moved
|
|
231
252
|
end
|
|
232
253
|
end
|
|
233
254
|
|
|
255
|
+
# Arguments are omitted deliberately: a reminder carries application data
|
|
256
|
+
# and this event exists to be logged.
|
|
257
|
+
# @rbs (Reminder, Time?) -> Hash[Symbol, untyped]?
|
|
258
|
+
def moved_reminder_payload(reminder, previous_run_at)
|
|
259
|
+
return nil unless previous_run_at
|
|
260
|
+
return nil if previous_run_at == reminder.next_run_at
|
|
261
|
+
|
|
262
|
+
{
|
|
263
|
+
actor_type: reminder.actor_type,
|
|
264
|
+
actor_id: reminder.actor_id,
|
|
265
|
+
name: reminder.name,
|
|
266
|
+
previous_run_at:,
|
|
267
|
+
next_run_at: reminder.next_run_at
|
|
268
|
+
}
|
|
269
|
+
end
|
|
270
|
+
|
|
234
271
|
# @rbs (Message, Instance, Array[Actor::OutboundMessageIntent]) -> Array[Effect]
|
|
235
272
|
def enqueue_actor_messages(locked_message, instance, intents)
|
|
236
273
|
intents.map do |intent|
|
|
@@ -12,12 +12,35 @@ module SolidObjects
|
|
|
12
12
|
test_case.teardown { reset_actors! }
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
+
# Deleting instances alone left every other actor-owned row to the
|
|
16
|
+
# database cascade. That cascade is not enforced everywhere: SQLite has
|
|
17
|
+
# to be asked for foreign keys, MySQL has to be on InnoDB, and a host
|
|
18
|
+
# application may have stripped the constraints out of the copied
|
|
19
|
+
# migration. Where it does not fire, rows survive into the next test with
|
|
20
|
+
# an instance_id pointing at nothing, and a test that reads them sees
|
|
21
|
+
# another test's data. Deleting each table costs nothing and does not
|
|
22
|
+
# depend on referential integrity.
|
|
15
23
|
# @rbs () -> void
|
|
16
24
|
def reset_actors!
|
|
17
25
|
SolidObjects.reset_caller_process!
|
|
18
|
-
|
|
26
|
+
actor_owned_models.each(&:delete_all)
|
|
19
27
|
Process.delete_all
|
|
20
28
|
end
|
|
29
|
+
|
|
30
|
+
# Children first, so the order is safe whether or not the cascade fires.
|
|
31
|
+
# @rbs () -> Array[Class]
|
|
32
|
+
def actor_owned_models
|
|
33
|
+
[
|
|
34
|
+
DeadLetter,
|
|
35
|
+
ClaimedMessage,
|
|
36
|
+
ReadyMessage,
|
|
37
|
+
Broadcast,
|
|
38
|
+
Effect,
|
|
39
|
+
Reminder,
|
|
40
|
+
Message,
|
|
41
|
+
Instance
|
|
42
|
+
]
|
|
43
|
+
end
|
|
21
44
|
end
|
|
22
45
|
|
|
23
46
|
# @rbs () -> void
|
|
@@ -45,8 +45,22 @@ module SolidObjects
|
|
|
45
45
|
# @rbs (Message, Instance, Array[Actor::EffectIntent]) -> Array[Effect]
|
|
46
46
|
def enqueue_effects: (Message, Instance, Array[Actor::EffectIntent]) -> Array[Effect]
|
|
47
47
|
|
|
48
|
-
#
|
|
49
|
-
|
|
48
|
+
# A reminder is one named alarm per actor, so scheduling a name that is
|
|
49
|
+
# already armed moves it rather than adding a second. An actor that arms a
|
|
50
|
+
# reminder per queued item therefore keeps only the last, and nothing else
|
|
51
|
+
# about that is visible: the write succeeds and the earlier wake-up simply
|
|
52
|
+
# never happens.
|
|
53
|
+
# Moves are returned rather than reported here, so the report happens after
|
|
54
|
+
# the turn commits. A rolled back turn would otherwise announce an alarm
|
|
55
|
+
# that never moved, which is the opposite of the visibility this event
|
|
56
|
+
# exists to provide.
|
|
57
|
+
# @rbs (Instance, Array[Actor::ReminderIntent]) -> Array[Hash[Symbol, untyped]]
|
|
58
|
+
def schedule_reminders: (Instance, Array[Actor::ReminderIntent]) -> Array[Hash[Symbol, untyped]]
|
|
59
|
+
|
|
60
|
+
# Arguments are omitted deliberately: a reminder carries application data
|
|
61
|
+
# and this event exists to be logged.
|
|
62
|
+
# @rbs (Reminder, Time?) -> Hash[Symbol, untyped]?
|
|
63
|
+
def moved_reminder_payload: (Reminder, Time?) -> Hash[Symbol, untyped]?
|
|
50
64
|
|
|
51
65
|
# @rbs (Message, Instance, Array[Actor::OutboundMessageIntent]) -> Array[Effect]
|
|
52
66
|
def enqueue_actor_messages: (Message, Instance, Array[Actor::OutboundMessageIntent]) -> Array[Effect]
|
|
@@ -5,9 +5,21 @@ module SolidObjects
|
|
|
5
5
|
# @rbs (Class) -> void
|
|
6
6
|
def self.included: (Class) -> void
|
|
7
7
|
|
|
8
|
+
# Deleting instances alone left every other actor-owned row to the
|
|
9
|
+
# database cascade. That cascade is not enforced everywhere: SQLite has
|
|
10
|
+
# to be asked for foreign keys, MySQL has to be on InnoDB, and a host
|
|
11
|
+
# application may have stripped the constraints out of the copied
|
|
12
|
+
# migration. Where it does not fire, rows survive into the next test with
|
|
13
|
+
# an instance_id pointing at nothing, and a test that reads them sees
|
|
14
|
+
# another test's data. Deleting each table costs nothing and does not
|
|
15
|
+
# depend on referential integrity.
|
|
8
16
|
# @rbs () -> void
|
|
9
17
|
def self.reset_actors!: () -> void
|
|
10
18
|
|
|
19
|
+
# Children first, so the order is safe whether or not the cascade fires.
|
|
20
|
+
# @rbs () -> Array[Class]
|
|
21
|
+
def self.actor_owned_models: () -> Array[Class]
|
|
22
|
+
|
|
11
23
|
# @rbs () -> void
|
|
12
24
|
def reset_actors!: () -> void
|
|
13
25
|
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: solid_objects
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.10.
|
|
4
|
+
version: 0.10.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Lucas Carlson
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-08-
|
|
11
|
+
date: 2026-08-11 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: actioncable
|