Skip to content

Commit 65ea299

Browse files
committed
docs: state that a reminder is one alarm per actor and name
schedule is keyed by actor and reminder name, so scheduling a name that is already armed moves that alarm instead of adding another. The behaviour is deliberate and matches Orleans and Durable Objects. It was stated nowhere, and it fails silently: an actor arming one reminder per queued item keeps only the last, and the earlier wake-ups never happen. A user shipped that to production and found it by hand. The reminders guide now gives the uniqueness key, shows the pattern that loses data, and shows the one-alarm-many-items pattern to use instead. That pattern is executed by a test rather than only read, since a sample nobody runs is how the next report gets written. Three tests pin the semantics being documented: a second schedule moves the alarm, distinct names coexist on one actor, and the same name on another actor is separate. A move that changes next_run_at now reports solid_objects.reminder.replaced carrying actor identity, name, and both times, with no arguments. Rescheduling to the same time reports nothing. The replacement was otherwise indistinguishable from a first schedule.
1 parent 99cfd5a commit 65ea299

8 files changed

Lines changed: 273 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
- Document that a reminder is one named alarm per actor. `schedule` is keyed by
6+
actor and reminder name, so scheduling a name that is already armed moves
7+
that alarm rather than adding a second. The behaviour is deliberate and
8+
matches Orleans and Durable Objects, but it was stated nowhere: an actor that
9+
armed one reminder per queued item silently kept only the last, and the
10+
earlier wake-ups never happened. The reminders guide now states the
11+
uniqueness key and shows the one-alarm-many-items pattern to use instead.
12+
- Add `solid_objects.reminder.replaced`, reported when a `schedule` call moves
13+
an alarm already armed under the same name to a different time. It carries
14+
the actor identity, reminder name, previous run time, and next run time, and
15+
no arguments. Rescheduling to the same time reports nothing. The replacement
16+
was previously indistinguishable from a first schedule.
17+
318
## 0.10.2 - 2026-08-10
419

520
- Load the mailbox when the gem is required. `SolidObjects::Mailbox` was

README.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -805,6 +805,62 @@ end
805805
Use `missed: :latest` to coalesce missed occurrences or `missed: :all` to
806806
enqueue each one.
807807

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+
808864
Self-scheduling actors should also have a low-frequency application reconciler.
809865
It may read `SolidObjects::Instance.states_for`, `.without_pending_work`, and
810866
`.orphaned`, but every repair must go through `async`. Never bulk-update actor

docs/architecture.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,8 @@ A reminder record contains actor identity, a reminder name, target message, JSON
439439
schedule :expire, at: 30.minutes.from_now, arguments: {}
440440
```
441441

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+
442444
When due, the scheduler locks the source instance and creates a normal mailbox
443445
row with an idempotency key derived from reminder ID and occurrence. The
444446
mailbox insert and reminder advancement commit atomically. The mailbox provides

docs/database-schema.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,8 @@ Indexes:
7777
### `reminders`
7878

7979
Durable one-shot or recurring alarm definitions. Unique instance/name makes
80-
rescheduling an actor-owned reminder deterministic. Status/next-run/ID drives
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
8182
the due scan. Claim ownership references the process registry, and constraints
8283
limit recurrence intervals, missed-work policies, and statuses.
8384

docs/operations.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,15 @@ transaction rejection, commit-action start/completion/failure, effect and
155155
broadcast enqueue/completion, reminder enqueue, actor destruction/expiration,
156156
retention pruning, process cleanup, and supervisor lifecycle.
157157

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+
158167
`solid_objects.component.refreshed` covers every authorized component refresh
159168
request. Its payload carries the actor identity, `component_name`,
160169
`component_key`, declared `dependencies`, `refresh_method`, the rendered

lib/solid_objects/executor.rb

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,10 +211,16 @@ def enqueue_effects(locked_message, instance, intents)
211211
end
212212
end
213213

214+
# A reminder is one named alarm per actor, so scheduling a name that is
215+
# already armed moves it rather than adding a second. An actor that arms a
216+
# reminder per queued item therefore keeps only the last, and nothing else
217+
# about that is visible: the write succeeds and the earlier wake-up simply
218+
# never happens.
214219
# @rbs (Instance, Array[Actor::ReminderIntent]) -> void
215220
def schedule_reminders(instance, intents)
216221
intents.each do |intent|
217222
reminder = Reminder.find_or_initialize_by(instance:, name: intent.name)
223+
previous_run_at = reminder.next_run_at
218224
reminder.assign_attributes(
219225
actor_type: instance.actor_type,
220226
actor_id: instance.actor_id,
@@ -227,10 +233,28 @@ def schedule_reminders(instance, intents)
227233
claimed_by: nil,
228234
claimed_at: nil
229235
)
236+
report_moved_reminder(reminder, previous_run_at)
230237
reminder.save!
231238
end
232239
end
233240

241+
# Arguments are omitted deliberately: a reminder carries application data
242+
# and this event exists to be logged.
243+
# @rbs (Reminder, Time?) -> void
244+
def report_moved_reminder(reminder, previous_run_at)
245+
return unless previous_run_at
246+
return if previous_run_at == reminder.next_run_at
247+
248+
SolidObjects.instrument(
249+
:"reminder.replaced",
250+
actor_type: reminder.actor_type,
251+
actor_id: reminder.actor_id,
252+
name: reminder.name,
253+
previous_run_at:,
254+
next_run_at: reminder.next_run_at
255+
)
256+
end
257+
234258
# @rbs (Message, Instance, Array[Actor::OutboundMessageIntent]) -> Array[Effect]
235259
def enqueue_actor_messages(locked_message, instance, intents)
236260
intents.map do |intent|

sig/generated/lib/solid_objects/executor.rbs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,19 @@ module SolidObjects
4545
# @rbs (Message, Instance, Array[Actor::EffectIntent]) -> Array[Effect]
4646
def enqueue_effects: (Message, Instance, Array[Actor::EffectIntent]) -> Array[Effect]
4747

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.
4853
# @rbs (Instance, Array[Actor::ReminderIntent]) -> void
4954
def schedule_reminders: (Instance, Array[Actor::ReminderIntent]) -> void
5055

56+
# Arguments are omitted deliberately: a reminder carries application data
57+
# and this event exists to be logged.
58+
# @rbs (Reminder, Time?) -> void
59+
def report_moved_reminder: (Reminder, Time?) -> void
60+
5161
# @rbs (Message, Instance, Array[Actor::OutboundMessageIntent]) -> Array[Effect]
5262
def enqueue_actor_messages: (Message, Instance, Array[Actor::OutboundMessageIntent]) -> Array[Effect]
5363

test/integration/reminders_test.rb

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,80 @@ def expire
2121
end
2222
end
2323

24+
class QueueActor < SolidObjects::Actor
25+
actor_type "reminder-queue"
26+
27+
attribute :entries, default: -> { [] }
28+
29+
def add(wait_until:)
30+
self.entries = entries + [ wait_until ]
31+
schedule :deliver, at: Time.at(wait_until).utc, arguments: {}
32+
end
33+
34+
def arm(name:, wait_until:)
35+
schedule name.to_sym, at: Time.at(wait_until).utc, arguments: {}
36+
end
37+
38+
def deliver
39+
self.entries = []
40+
end
41+
end
42+
43+
# The pattern the reminders guide recommends instead of one alarm per item.
44+
# It is documented, so it is executed here rather than only read.
45+
class QueueDrainActor < SolidObjects::Actor
46+
actor_type "reminder-drain"
47+
48+
attribute :entries, default: -> { [] }
49+
attribute :delivered, default: -> { [] }
50+
51+
def add(wait_until:)
52+
self.entries = (entries + [ wait_until ]).sort
53+
arm_next
54+
end
55+
56+
def deliver
57+
now = Time.now.to_i
58+
due, pending = entries.partition { |wait_until| wait_until <= now }
59+
self.delivered = delivered + due
60+
self.entries = pending
61+
arm_next
62+
end
63+
64+
private
65+
66+
def arm_next
67+
earliest = entries.first
68+
return unless earliest
69+
70+
schedule :deliver, at: Time.at(earliest).utc, arguments: {}
71+
end
72+
end
73+
74+
test "one alarm drains every due item and arms the next" do
75+
reference = QueueDrainActor.ref("table")
76+
due = 10.seconds.ago.to_i
77+
later = 1.hour.from_now.to_i
78+
reference.async(:add, wait_until: due)
79+
reference.async(:add, wait_until: later)
80+
worker = SolidObjects::Worker.new
81+
worker.run_until_idle
82+
scheduler = SolidObjects::ReminderScheduler.new
83+
84+
assert scheduler.run_once, "the earliest item should have armed the alarm"
85+
worker.run_until_idle
86+
87+
state = SolidObjects::Instance.find_by!(actor_type: "reminder-drain").state
88+
assert_equal [ due ], state.fetch("delivered")
89+
assert_equal [ later ], state.fetch("entries")
90+
reminder = SolidObjects::Reminder.find_by!(actor_type: "reminder-drain")
91+
assert_equal later, reminder.next_run_at.to_i,
92+
"the handler should have armed the next item before finishing"
93+
ensure
94+
scheduler&.stop
95+
worker&.stop
96+
end
97+
2498
test "persists a reminder in the actor commit" do
2599
message_reference = ExpiringActor.ref("one").async(:configure_expiration)
26100
worker = SolidObjects::Worker.new
@@ -78,4 +152,85 @@ def expire
78152

79153
assert_not actor.respond_to?(:remind, true)
80154
end
155+
156+
# A reminder is one named alarm per actor, so a second schedule with the same
157+
# name moves the existing alarm rather than adding one. An actor that arms a
158+
# reminder per queued item therefore keeps only the last, which is silent
159+
# data loss if the caller expected an alarm each.
160+
test "a second schedule with the same name moves the existing reminder" do
161+
reference = QueueActor.ref("table")
162+
first = 1.hour.from_now.to_i
163+
second = 2.hours.from_now.to_i
164+
reference.async(:add, wait_until: first)
165+
reference.async(:add, wait_until: second)
166+
worker = SolidObjects::Worker.new
167+
worker.run_until_idle
168+
169+
reminders = SolidObjects::Reminder.where(actor_type: "reminder-queue")
170+
assert_equal 1, reminders.count,
171+
"two entries but one named reminder, which is the whole hazard"
172+
assert_equal second, reminders.sole.next_run_at.to_i
173+
end
174+
175+
test "reminders with different names on one actor coexist" do
176+
reference = QueueActor.ref("table")
177+
reference.async(:arm, name: "deliver", wait_until: 1.hour.from_now.to_i)
178+
reference.async(:arm, name: "sweep", wait_until: 2.hours.from_now.to_i)
179+
worker = SolidObjects::Worker.new
180+
worker.run_until_idle
181+
182+
assert_equal %w[deliver sweep],
183+
SolidObjects::Reminder.where(actor_type: "reminder-queue").order(:name).pluck(:name)
184+
end
185+
186+
test "the same reminder name on another actor is a separate reminder" do
187+
QueueActor.ref("one").async(:add, wait_until: 1.hour.from_now.to_i)
188+
QueueActor.ref("two").async(:add, wait_until: 2.hours.from_now.to_i)
189+
worker = SolidObjects::Worker.new
190+
worker.run_until_idle
191+
192+
assert_equal 2, SolidObjects::Reminder.where(actor_type: "reminder-queue").count
193+
end
194+
195+
# The replacement is otherwise invisible, and finding it in production means
196+
# finding it by noticing something never happened.
197+
test "moving a reminder is instrumented" do
198+
events = []
199+
subscription = ActiveSupport::Notifications.subscribe("solid_objects.reminder.replaced") do |event|
200+
events << event.payload
201+
end
202+
reference = QueueActor.ref("table")
203+
reference.async(:add, wait_until: 1.hour.from_now.to_i)
204+
reference.async(:add, wait_until: 2.hours.from_now.to_i)
205+
worker = SolidObjects::Worker.new
206+
worker.run_until_idle
207+
208+
assert_equal 1, events.length, "only the replacement should report"
209+
assert_equal "reminder-queue", events.sole.fetch(:actor_type)
210+
assert_equal "table", events.sole.fetch(:actor_id)
211+
assert_equal "deliver", events.sole.fetch(:name)
212+
refute_equal events.sole.fetch(:previous_run_at), events.sole.fetch(:next_run_at)
213+
ensure
214+
ActiveSupport::Notifications.unsubscribe(subscription) if subscription
215+
worker&.stop
216+
end
217+
218+
test "rescheduling to the same time reports nothing" do
219+
events = []
220+
subscription = ActiveSupport::Notifications.subscribe("solid_objects.reminder.replaced") do |event|
221+
events << event.payload
222+
end
223+
wait_until = 1.hour.from_now.to_i
224+
reference = QueueActor.ref("table")
225+
reference.async(:add, wait_until:)
226+
reference.async(:add, wait_until:)
227+
worker = SolidObjects::Worker.new
228+
worker.run_until_idle
229+
230+
assert_empty events,
231+
"a reschedule that moves nothing is not the hazard worth reporting"
232+
ensure
233+
ActiveSupport::Notifications.unsubscribe(subscription) if subscription
234+
worker&.stop
235+
end
81236
end

0 commit comments

Comments
 (0)