Skip to content

Commit 9bab99c

Browse files
authored
Merge pull request #41 from cardmagic/feat/keyed-reminders
Let a reminder be named for the item it waits on
2 parents 07d180d + 676b4c6 commit 9bab99c

8 files changed

Lines changed: 302 additions & 26 deletions

File tree

CHANGELOG.md

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

3+
## Unreleased
4+
5+
- Accept a `key:` on `schedule`, naming a reminder for the item it is waiting
6+
on rather than for its operation, so one actor can hold an alarm per queued
7+
item. Scheduling the same key again moves that item's alarm and leaves the
8+
others alone. Without a key the name is still the operation, so existing
9+
reminders keep their names and their coalescing behaviour. A reminder
10+
operation may no longer hold the colon that separates a key, which keeps
11+
keyed and unkeyed names disjoint, and the length is checked on the composed
12+
name rather than the key alone.
13+
314
## 0.13.2 - 2026-08-16
415

516
- Add an authorized `SolidObjects.administration.processes` query for

README.md

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -891,7 +891,7 @@ database enforces this with a unique index on `(instance_id, name)`.
891891

892892
This is the same model as Orleans reminders and Durable Objects alarms, and it
893893
is what makes a reminder safe to re-arm from a handler that may run more than
894-
once. It also means this is a data-loss bug:
894+
once. Without a key the name is the operation, so this is a data-loss bug:
895895

896896
```ruby
897897
# Wrong. Every entry overwrites the previous entry's alarm.
@@ -904,8 +904,38 @@ end
904904
Two entries leave one reminder. The earlier wake-up never happens, nothing
905905
raises, and nothing is logged except a `solid_objects.reminder.replaced` event.
906906

907-
Arm one alarm for the earliest item instead, and let the handler drain
908-
everything now due before arming the next:
907+
### An alarm per item, with `key:`
908+
909+
Pass `key:` when an actor is waiting on several things at once. The key is your
910+
own identifier for the item, and it names that item's alarm, so each item gets
911+
one:
912+
913+
```ruby
914+
def add(entry:)
915+
self.entries = entries + [ entry ]
916+
schedule(at: entry.fetch("wait_until"), key: entry.fetch("id")).deliver
917+
end
918+
```
919+
920+
Two entries now leave two reminders. Scheduling the same key again moves that
921+
item's alarm and leaves the others alone, which is what makes a keyed reminder
922+
as safe to re-arm as an unkeyed one. The operation still decides which handler
923+
runs; the key only decides which alarm is which.
924+
925+
A key must be non-empty, and the name it becomes must fit the 191-character
926+
column, which is checked on the composed name rather than the key alone so a
927+
long operation and a short key are caught too.
928+
929+
The key is separated from the operation by a colon, so an operation may not hold
930+
one. Otherwise an unkeyed `deliver:item` and a `deliver` keyed `item` would be
931+
one name, and the second would silently take the first one's alarm. A key may
932+
hold colons of its own, because the operation before the first one cannot.
933+
934+
### One alarm for a whole queue
935+
936+
A key per item is not always what you want. An actor that only ever needs to
937+
know "what is next" can keep one alarm and let the handler drain everything now
938+
due before arming the next:
909939

910940
```ruby
911941
def add(entry:)
@@ -931,10 +961,10 @@ def arm_next
931961
end
932962
```
933963

934-
`deliver` drains every due item rather than one, so a single alarm serves a
935-
whole queue and a missed or coalesced occurrence cannot strand an entry. Use a
936-
distinct reminder name only when you genuinely need two independent alarms on
937-
one actor, such as `:deliver` and `:sweep`.
964+
That costs one reminder row instead of one per item, and a coalesced occurrence
965+
cannot strand an entry because the handler drains by time rather than by alarm.
966+
Prefer it when the queue is large and the items are interchangeable; prefer
967+
`key:` when an item needs its own alarm that can be moved on its own.
938968

939969
Solid Objects has no `unschedule`. A reminder stops when its handler does not
940970
re-arm it, and destroying an actor removes its reminders.

lib/solid_objects/actor.rb

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ module SolidObjects
44
class Actor
55
EffectIntent = Data.define(:name, :arguments, :success_operation, :failure_operation)
66
CommitActionIntent = Data.define(:name, :arguments)
7-
ReminderIntent = Data.define(:name, :at, :arguments, :interval_seconds, :missed_policy)
7+
# The reminders table holds a name in 191 characters.
8+
REMINDER_NAME_LIMIT = 191
9+
REMINDER_KEY_SEPARATOR = ":"
10+
11+
ReminderIntent = Data.define(:name, :operation, :at, :arguments, :interval_seconds, :missed_policy)
812
OutboundMessageIntent = Data.define(:actor_type, :actor_id, :operation, :arguments, :available_at, :idempotency_key)
913

1014
class << self
@@ -197,8 +201,13 @@ def commit_action(name, **arguments)
197201
nil
198202
end
199203

200-
# @rbs (at: Time, ?every: Numeric?, ?missed: Symbol | String) -> OperationDispatcher
201-
def schedule(at:, every: nil, missed: :latest)
204+
# A reminder is identified by its name, and without a key that name is the
205+
# operation, so one actor holds one alarm per operation. A key gives an actor
206+
# an alarm per item it is waiting on, which is what an actor holding a queue
207+
# of scheduled work needs; the key is the caller's own identifier for the
208+
# item, and scheduling the same key again moves that item's alarm.
209+
# @rbs (at: Time, ?every: Numeric?, ?missed: Symbol | String, ?key: (String | Symbol | Integer)?) -> OperationDispatcher
210+
def schedule(at:, every: nil, missed: :latest, key: nil)
202211
interval_seconds = every&.to_f
203212
if interval_seconds && !interval_seconds.positive?
204213
raise ArgumentError, "reminder interval must be positive"
@@ -207,13 +216,15 @@ def schedule(at:, every: nil, missed: :latest)
207216
unless %w[all latest].include?(missed_policy)
208217
raise ArgumentError, "missed reminder policy must be all or latest"
209218
end
219+
reminder_key = validated_reminder_key(key)
210220

211221
OperationDispatcher.new(
212222
actor_type: self.class.actor_type,
213223
handlers: self.class.definition.messages
214224
) do |operation, arguments|
215225
ReminderIntent.new(
216-
name: operation.to_s,
226+
name: reminder_name(operation:, key: reminder_key),
227+
operation: operation.to_s,
217228
at:,
218229
arguments: Serialization.dump(arguments),
219230
interval_seconds:,
@@ -225,6 +236,45 @@ def schedule(at:, every: nil, missed: :latest)
225236
end
226237
end
227238

239+
# @rbs ((String | Symbol | Integer)?) -> String?
240+
def validated_reminder_key(key)
241+
return nil if key.nil?
242+
243+
reminder_key = key.to_s
244+
raise ArgumentError, "reminder key must not be empty" if reminder_key.empty?
245+
246+
reminder_key
247+
end
248+
249+
# A keyed name is the operation, a colon, and the key, so an operation
250+
# holding a colon of its own would make two different schedules produce one
251+
# name: an unkeyed "deliver:item" and a "deliver" keyed "item" would share a
252+
# row, and the second would silently take the first one's alarm. Refusing a
253+
# colon in the operation keeps unkeyed names free of colons, which leaves
254+
# the two kinds of name disjoint and lets a key hold colons of its own.
255+
#
256+
# The length is checked on the composed name rather than the key alone,
257+
# because a long operation and a short key can exceed the column just as
258+
# easily as the reverse. Both are refused here rather than at the insert,
259+
# once the turn is already doing work.
260+
# @rbs (operation: Symbol | String, key: String?) -> String
261+
def reminder_name(operation:, key:)
262+
operation_name = operation.to_s
263+
if operation_name.include?(REMINDER_KEY_SEPARATOR)
264+
raise ArgumentError,
265+
"reminder operation #{operation_name.inspect} must not contain #{REMINDER_KEY_SEPARATOR.inspect}"
266+
end
267+
return operation_name if key.nil?
268+
269+
name = "#{operation_name}#{REMINDER_KEY_SEPARATOR}#{key}"
270+
if name.length > REMINDER_NAME_LIMIT
271+
raise ArgumentError,
272+
"reminder name #{name.length} characters exceeds the #{REMINDER_NAME_LIMIT} the database holds"
273+
end
274+
275+
name
276+
end
277+
228278
# @rbs (Reference, ?available_at: Time?, ?idempotency_key: String?) -> OperationDispatcher
229279
def send_to(reference, available_at: nil, idempotency_key: nil)
230280
actor_class = SolidObjects.registry.fetch(reference.actor_type)

lib/solid_objects/executor.rb

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -223,10 +223,10 @@ def enqueue_effects(message:, instance:, intents:)
223223
end
224224

225225
# A reminder is one named alarm per actor, so scheduling a name that is
226-
# already armed moves it rather than adding a second. An actor that arms a
227-
# reminder per queued item therefore keeps only the last, and nothing else
228-
# about that is visible: the write succeeds and the earlier wake-up simply
229-
# never happens.
226+
# already armed moves it rather than adding a second. Without a key that
227+
# name is the operation, so an actor arming a reminder per queued item keeps
228+
# only the last; passing schedule a key gives each item its own name and so
229+
# its own alarm.
230230
# Moves are returned rather than reported here, so the report happens after
231231
# the turn commits. A rolled back turn would otherwise announce an alarm
232232
# that never moved, which is the opposite of the visibility this event
@@ -239,7 +239,7 @@ def schedule_reminders(instance, intents)
239239
reminder.assign_attributes(
240240
actor_type: instance.actor_type,
241241
actor_id: instance.actor_id,
242-
operation: intent.name,
242+
operation: intent.operation,
243243
arguments: intent.arguments,
244244
next_run_at: intent.at,
245245
interval_seconds: intent.interval_seconds,

sig/generated/lib/solid_objects/actor.rbs

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,16 @@ module SolidObjects
3232
def members: () -> [ :name, :arguments ]
3333
end
3434

35+
# The reminders table holds a name in 191 characters.
36+
REMINDER_NAME_LIMIT: ::Integer
37+
38+
REMINDER_KEY_SEPARATOR: ::String
39+
3540
class ReminderIntent < Data
3641
attr_reader name(): untyped
3742

43+
attr_reader operation(): untyped
44+
3845
attr_reader at(): untyped
3946

4047
attr_reader arguments(): untyped
@@ -43,12 +50,12 @@ module SolidObjects
4350

4451
attr_reader missed_policy(): untyped
4552

46-
def self.new: (untyped name, untyped at, untyped arguments, untyped interval_seconds, untyped missed_policy) -> instance
47-
| (name: untyped, at: untyped, arguments: untyped, interval_seconds: untyped, missed_policy: untyped) -> instance
53+
def self.new: (untyped name, untyped operation, untyped at, untyped arguments, untyped interval_seconds, untyped missed_policy) -> instance
54+
| (name: untyped, operation: untyped, at: untyped, arguments: untyped, interval_seconds: untyped, missed_policy: untyped) -> instance
4855

49-
def self.members: () -> [ :name, :at, :arguments, :interval_seconds, :missed_policy ]
56+
def self.members: () -> [ :name, :operation, :at, :arguments, :interval_seconds, :missed_policy ]
5057

51-
def members: () -> [ :name, :at, :arguments, :interval_seconds, :missed_policy ]
58+
def members: () -> [ :name, :operation, :at, :arguments, :interval_seconds, :missed_policy ]
5259
end
5360

5461
class OutboundMessageIntent < Data
@@ -157,8 +164,30 @@ module SolidObjects
157164
# @rbs (Symbol | String, **untyped) -> nil
158165
def commit_action: (Symbol | String, **untyped) -> nil
159166

160-
# @rbs (at: Time, ?every: Numeric?, ?missed: Symbol | String) -> OperationDispatcher
161-
def schedule: (at: Time, ?every: Numeric?, ?missed: Symbol | String) -> OperationDispatcher
167+
# A reminder is identified by its name, and without a key that name is the
168+
# operation, so one actor holds one alarm per operation. A key gives an actor
169+
# an alarm per item it is waiting on, which is what an actor holding a queue
170+
# of scheduled work needs; the key is the caller's own identifier for the
171+
# item, and scheduling the same key again moves that item's alarm.
172+
# @rbs (at: Time, ?every: Numeric?, ?missed: Symbol | String, ?key: (String | Symbol | Integer)?) -> OperationDispatcher
173+
def schedule: (at: Time, ?every: Numeric?, ?missed: Symbol | String, ?key: (String | Symbol | Integer)?) -> OperationDispatcher
174+
175+
# @rbs ((String | Symbol | Integer)?) -> String?
176+
def validated_reminder_key: ((String | Symbol | Integer)?) -> String?
177+
178+
# A keyed name is the operation, a colon, and the key, so an operation
179+
# holding a colon of its own would make two different schedules produce one
180+
# name: an unkeyed "deliver:item" and a "deliver" keyed "item" would share a
181+
# row, and the second would silently take the first one's alarm. Refusing a
182+
# colon in the operation keeps unkeyed names free of colons, which leaves
183+
# the two kinds of name disjoint and lets a key hold colons of its own.
184+
#
185+
# The length is checked on the composed name rather than the key alone,
186+
# because a long operation and a short key can exceed the column just as
187+
# easily as the reverse. Both are refused here rather than at the insert,
188+
# once the turn is already doing work.
189+
# @rbs (operation: Symbol | String, key: String?) -> String
190+
def reminder_name: (operation: Symbol | String, key: String?) -> String
162191

163192
# @rbs (Reference, ?available_at: Time?, ?idempotency_key: String?) -> OperationDispatcher
164193
def send_to: (Reference, ?available_at: Time?, ?idempotency_key: String?) -> OperationDispatcher

sig/generated/lib/solid_objects/executor.rbs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,10 @@ module SolidObjects
4646
def enqueue_effects: (message: Message, instance: Instance, intents: Array[Actor::EffectIntent]) -> Array[Effect]
4747

4848
# 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.
49+
# already armed moves it rather than adding a second. Without a key that
50+
# name is the operation, so an actor arming a reminder per queued item keeps
51+
# only the last; passing schedule a key gives each item its own name and so
52+
# its own alarm.
5353
# Moves are returned rather than reported here, so the report happens after
5454
# the turn commits. A rolled back turn would otherwise announce an alarm
5555
# that never moved, which is the opposite of the visibility this event

test/integration/reminders_test.rb

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ def add(wait_until:)
3131
schedule(at: Time.at(wait_until).utc).deliver
3232
end
3333

34+
def add_keyed(item:, wait_until:)
35+
self.entries = entries + [ wait_until ]
36+
schedule(at: Time.at(wait_until).utc, key: item).deliver
37+
end
38+
3439
def arm(name:, wait_until:)
3540
schedule(at: Time.at(wait_until).utc).public_send(name.to_sym)
3641
end
@@ -160,6 +165,58 @@ def arm_next
160165
# name moves the existing alarm rather than adding one. An actor that arms a
161166
# reminder per queued item therefore keeps only the last, which is silent
162167
# data loss if the caller expected an alarm each.
168+
test "a key gives each queued item its own reminder" do
169+
reference = QueueActor.ref("keyed")
170+
first = 1.hour.from_now.to_i
171+
second = 2.hours.from_now.to_i
172+
reference.async.add_keyed(item: "a", wait_until: first)
173+
reference.async.add_keyed(item: "b", wait_until: second)
174+
SolidObjects::Worker.new.run_until_idle
175+
176+
reminders = SolidObjects::Reminder.where(actor_type: "reminder-queue").order(:next_run_at)
177+
assert_equal 2, reminders.count, "each key is its own alarm"
178+
assert_equal [ first, second ], reminders.map { |reminder| reminder.next_run_at.to_i }
179+
end
180+
181+
test "a keyed reminder still runs the operation it was scheduled with" do
182+
reference = QueueActor.ref("keyed-operation")
183+
reference.async.add_keyed(item: "a", wait_until: 1.hour.from_now.to_i)
184+
SolidObjects::Worker.new.run_until_idle
185+
186+
reminder = SolidObjects::Reminder.where(actor_type: "reminder-queue").sole
187+
assert_equal "deliver", reminder.operation
188+
assert_equal "deliver:a", reminder.name
189+
end
190+
191+
test "scheduling the same key again moves that item's reminder" do
192+
reference = QueueActor.ref("keyed-move")
193+
reference.async.add_keyed(item: "a", wait_until: 1.hour.from_now.to_i)
194+
moved = 3.hours.from_now.to_i
195+
reference.async.add_keyed(item: "a", wait_until: moved)
196+
SolidObjects::Worker.new.run_until_idle
197+
198+
reminders = SolidObjects::Reminder.where(actor_type: "reminder-queue")
199+
assert_equal 1, reminders.count, "the same key is the same alarm"
200+
assert_equal moved, reminders.sole.next_run_at.to_i
201+
end
202+
203+
test "a keyed reminder delivers to its actor" do
204+
reference = QueueActor.ref("keyed-delivery")
205+
reference.async.add_keyed(item: "a", wait_until: 2.seconds.ago.to_i)
206+
worker = SolidObjects::Worker.new
207+
worker.run_until_idle
208+
scheduler = SolidObjects::ReminderScheduler.new
209+
210+
assert scheduler.run_once, "the keyed alarm should have come due"
211+
worker.run_until_idle
212+
213+
state = SolidObjects::Instance.find_by!(actor_type: "reminder-queue", actor_id: "keyed-delivery").state
214+
assert_empty state.fetch("entries")
215+
ensure
216+
scheduler&.stop
217+
worker&.stop
218+
end
219+
163220
test "a second schedule with the same name moves the existing reminder" do
164221
reference = QueueActor.ref("table")
165222
first = 1.hour.from_now.to_i
@@ -228,13 +285,15 @@ def arm_next
228285
)
229286
intent = SolidObjects::Actor::ReminderIntent.new(
230287
name: "deliver",
288+
operation: "deliver",
231289
at: 1.hour.from_now,
232290
arguments: {},
233291
interval_seconds: nil,
234292
missed_policy: "latest"
235293
)
236294
later = SolidObjects::Actor::ReminderIntent.new(
237295
name: "deliver",
296+
operation: "deliver",
238297
at: 2.hours.from_now,
239298
arguments: {},
240299
interval_seconds: nil,

0 commit comments

Comments
 (0)