Skip to content

Commit 007cd8a

Browse files
authored
Merge pull request #30 from cardmagic/agent/document-reminder-uniqueness
docs: state that a reminder is one alarm per actor and name
2 parents 075af9a + 21a5803 commit 007cd8a

8 files changed

Lines changed: 331 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,18 @@
1313
test's rows and failed depending on order. Reported as reminders leaking,
1414
which is where it surfaces first because reminders outlive the message that
1515
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.
1628

1729
## 0.10.2 - 2026-08-10
1830

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: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,14 @@ def complete(result, observable_changes, state_changed:)
9090
reminder_intents = actor.drain_reminder_intents
9191
outbound_message_intents = actor.drain_outbound_message_intents
9292
enqueued_effects = []
93+
moved_reminders = []
9394

9495
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
95101
claimed_message = matching_claim!
96102
locked_message = Message.lock.find(message.id)
97103
execute_commit_actions(commit_action_intents)
@@ -107,7 +113,7 @@ def complete(result, observable_changes, state_changed:)
107113
completed_at: SolidObjects.database_adapter.database_now
108114
)
109115
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))
111117
enqueued_effects.concat(
112118
enqueue_actor_messages(locked_message, instance, outbound_message_intents)
113119
)
@@ -127,6 +133,9 @@ def complete(result, observable_changes, state_changed:)
127133
observable_name:
128134
)
129135
end
136+
moved_reminders.each do |moved|
137+
SolidObjects.instrument(:"reminder.replaced", **moved)
138+
end
130139
enqueued_effects.each do |effect|
131140
SolidObjects.instrument(
132141
:"effect.enqueued",
@@ -211,10 +220,20 @@ def enqueue_effects(locked_message, instance, intents)
211220
end
212221
end
213222

214-
# @rbs (Instance, Array[Actor::ReminderIntent]) -> void
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]]
215233
def schedule_reminders(instance, intents)
216-
intents.each do |intent|
234+
intents.filter_map do |intent|
217235
reminder = Reminder.find_or_initialize_by(instance:, name: intent.name)
236+
previous_run_at = reminder.next_run_at
218237
reminder.assign_attributes(
219238
actor_type: instance.actor_type,
220239
actor_id: instance.actor_id,
@@ -227,10 +246,28 @@ def schedule_reminders(instance, intents)
227246
claimed_by: nil,
228247
claimed_at: nil
229248
)
249+
moved = moved_reminder_payload(reminder, previous_run_at)
230250
reminder.save!
251+
moved
231252
end
232253
end
233254

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+
234271
# @rbs (Message, Instance, Array[Actor::OutboundMessageIntent]) -> Array[Effect]
235272
def enqueue_actor_messages(locked_message, instance, intents)
236273
intents.map do |intent|

sig/generated/lib/solid_objects/executor.rbs

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

48-
# @rbs (Instance, Array[Actor::ReminderIntent]) -> void
49-
def schedule_reminders: (Instance, Array[Actor::ReminderIntent]) -> void
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]?
5064

5165
# @rbs (Message, Instance, Array[Actor::OutboundMessageIntent]) -> Array[Effect]
5266
def enqueue_actor_messages: (Message, Instance, Array[Actor::OutboundMessageIntent]) -> Array[Effect]

0 commit comments

Comments
 (0)