Skip to content

Commit dde241e

Browse files
authored
Merge pull request #75 from cardmagic/feat/reminder-cancellation
feat: cancel and read actor reminders
2 parents c1da8fc + 55ba300 commit dde241e

14 files changed

Lines changed: 821 additions & 28 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,28 @@
88
another process, so its write cannot clear that cache. A poll inside a request,
99
a job, or `rails runner` reported the first answer forever. The synchronous
1010
wait already read uncached.
11+
- Add reminder cancellation. `unschedule` removes one reminder by operation and
12+
optional key, or by the handle `schedule` now returns. `unschedule_all`
13+
removes every key of one operation. Both stage an intent, so a cancel commits
14+
with the state change that decided it, and a turn that raises cancels nothing.
15+
- Add reminder reading. `reminder` returns a `ReminderStatus` or `nil`, and
16+
`reminders` lists every key of one operation. A read applies the intents
17+
staged in the current turn, so it agrees with what the commit will write. An
18+
actor reads its own schedule from every path, including activation hooks and
19+
observables, because it carries its instance rather than reading an ambient
20+
context that only message dispatch establishes.
21+
- Leave a one-shot reminder that already fired out of `reminder` and
22+
`reminders`. Its row stays as `completed`, so a next-run lookup reported an
23+
old time rather than nothing, and an existence check refused to re-arm an
24+
alarm that could never fire again.
25+
- Refuse an unknown operation in `reminder`, `reminders`, `unschedule`, and
26+
`unschedule_all`. `schedule` already raised `UnknownMessage` for one, so a
27+
typo cancelled nothing quietly and left a recurring reminder running.
28+
- A cancel cannot recall an occurrence the scheduler already turned into a
29+
message. It does pre-empt one the scheduler claimed but has not yet enqueued.
30+
- `schedule` now returns a reminder handle instead of `nil`. An operation that
31+
ends with `schedule` and relies on an implicit `nil` result should return
32+
`nil` explicitly, as `emit` required in 0.15.0.
1133

1234
## 0.15.2 - 2026-09-21
1335

‎docs/roadmap.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,14 @@
3636
listed here while broken in that worker: the scheduler reached a constant the
3737
caller path happened to load, so reminders never fired in production and
3838
every in-process test still passed
39+
- Reminder cancellation and reading. `schedule` returns a durable handle,
40+
`unschedule` and `unschedule_all` cancel by name, key, or handle, and
41+
`reminder` and `reminders` read the schedule. A cancel is an intent, so it
42+
commits with the state change that decided it. A read applies the intents
43+
staged so far, so it agrees with what the commit will leave behind. A cancel
44+
cannot recall an occurrence the scheduler already turned into a message. It
45+
does pre-empt one the scheduler claimed but has not yet enqueued, and the
46+
scheduler treats that as ordinary work rather than a failure
3947
- Durable invalidation-only observable broadcasts by default, explicit
4048
`broadcast: :value` scalar Turbo replacement, keyed ERB components, signed
4149
component locals, and authorized replace or morph refresh. Default

‎lib/solid_objects/activation.rb‎

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
module SolidObjects
44
class Activation
55
# @rbs @lease: Lease
6+
# @rbs @actor_id: String
7+
# @rbs @instance_id: Integer
68
# @rbs @actor_class: Class
79
# @rbs @actor: Actor
810
# @rbs @last_used_at: Float
@@ -17,6 +19,8 @@ def initialize(lease:)
1719
Instance.find(lease.instance_id)
1820
end
1921
@actor_class = SolidObjects.registry.fetch(instance.actor_type)
22+
@actor_id = instance.actor_id
23+
@instance_id = instance.id
2024
@actor = build_actor(instance)
2125
@last_used_at = monotonic_now
2226
@pass_exhausted = false
@@ -86,10 +90,7 @@ def yield_ready_messages
8690

8791
# @rbs (Hash[String, untyped]) -> void
8892
def restore_state(state_data)
89-
@actor = actor_class.new(
90-
actor_id: actor.actor_id,
91-
state: State.new(actor_class.definition.state_definition, state_data)
92-
)
93+
@actor = new_actor(state_data)
9394
end
9495

9596
# @rbs () -> void
@@ -156,9 +157,15 @@ def build_actor(instance)
156157
) do
157158
actor_class.definition.migrate_state(instance.state_version, instance.state)
158159
end
160+
new_actor(state_data)
161+
end
162+
163+
# @rbs (Hash[String, untyped]) -> Actor
164+
def new_actor(state_data)
159165
actor_class.new(
160-
actor_id: instance.actor_id,
161-
state: State.new(actor_class.definition.state_definition, state_data)
166+
actor_id: @actor_id,
167+
state: State.new(actor_class.definition.state_definition, state_data),
168+
instance_id: @instance_id
162169
)
163170
end
164171

‎lib/solid_objects/actor.rb‎

Lines changed: 134 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,13 @@ class Actor
1010
REMINDER_NAME_LIMIT = 191
1111
REMINDER_KEY_SEPARATOR = ":"
1212

13+
REMINDER_HANDLE_KEY = "reminder_name"
14+
1315
ReminderIntent = Data.define(:name, :operation, :at, :arguments, :interval_seconds, :missed_policy)
16+
UnscheduleIntent = Data.define(:name)
17+
UnscheduleAllIntent = Data.define(:operation)
18+
ReminderStatus = Data.define(:name, :operation, :key, :next_run_at, :interval_seconds,
19+
:missed_policy, :occurrence, :status, :handle)
1420
OutboundMessageIntent = Data.define(:actor_type, :actor_id, :operation, :arguments, :available_at, :idempotency_key)
1521

1622
class << self
@@ -152,10 +158,11 @@ def default_actor_type
152158

153159
attr_reader :actor_id, :state
154160

155-
# @rbs (actor_id: String, state: State) -> void
156-
def initialize(actor_id:, state:)
161+
# @rbs (actor_id: String, state: State, ?instance_id: Integer?) -> void
162+
def initialize(actor_id:, state:, instance_id: nil)
157163
@actor_id = actor_id
158164
@state = state
165+
@instance_id = instance_id
159166
@effect_intents = []
160167
@effect_recovery_intents = []
161168
@commit_action_intents = []
@@ -255,20 +262,139 @@ def schedule(at:, every: nil, missed: :latest, key: nil)
255262
actor_type: self.class.actor_type,
256263
handlers: self.class.definition.messages
257264
) do |operation, arguments|
258-
ReminderIntent.new(
259-
name: reminder_name(operation:, key: reminder_key),
265+
name = reminder_name(operation:, key: reminder_key)
266+
reminder_intents << ReminderIntent.new(
267+
name:,
260268
operation: operation.to_s,
261269
at:,
262270
arguments: Serialization.dump(arguments),
263271
interval_seconds:,
264272
missed_policy:
265-
).tap do |intent|
266-
reminder_intents << intent
267-
end
268-
nil
273+
)
274+
{ REMINDER_HANDLE_KEY => name }
269275
end
270276
end
271277

278+
# @rbs (Symbol | String | reminder_handle, ?key: (String | Symbol | Integer)?) -> nil
279+
def unschedule(operation_or_handle, key: nil)
280+
return unschedule_name(handle_name(operation_or_handle, key:)) if operation_or_handle.is_a?(Hash)
281+
282+
validated_reminder_operation(operation_or_handle)
283+
unschedule_name(reminder_name(operation: operation_or_handle, key: validated_reminder_key(key)))
284+
end
285+
286+
# @rbs (Symbol | String) -> nil
287+
def unschedule_all(operation)
288+
reminder_intents << UnscheduleAllIntent.new(operation: validated_reminder_operation(operation))
289+
nil
290+
end
291+
292+
# @rbs (Symbol | String | reminder_handle, ?key: (String | Symbol | Integer)?) -> ReminderStatus?
293+
def reminder(operation_or_handle, key: nil)
294+
return reminder_view[handle_name(operation_or_handle, key:)] if operation_or_handle.is_a?(Hash)
295+
296+
validated_reminder_operation(operation_or_handle)
297+
reminder_view[reminder_name(operation: operation_or_handle, key: validated_reminder_key(key))]
298+
end
299+
300+
# @rbs (Symbol | String) -> Array[ReminderStatus]
301+
def reminders(operation)
302+
wanted = validated_reminder_operation(operation)
303+
reminder_view.each_value.select { |status| status.operation == wanted }
304+
end
305+
306+
attr_reader :instance_id
307+
308+
# @rbs (Symbol | String) -> String
309+
def validated_reminder_operation(operation)
310+
name = operation.to_s
311+
return name if self.class.definition.messages.key?(name.to_sym)
312+
313+
raise UnknownMessage, "unknown message #{name.inspect} for #{self.class.actor_type}"
314+
end
315+
316+
# @rbs (String) -> nil
317+
def unschedule_name(name)
318+
reminder_intents << UnscheduleIntent.new(name:)
319+
nil
320+
end
321+
322+
# @rbs (reminder_handle, key: untyped) -> String
323+
def handle_name(handle, key:)
324+
raise ArgumentError, "a reminder handle already names its key" unless key.nil?
325+
326+
name = handle[REMINDER_HANDLE_KEY]
327+
unless name.is_a?(String) && !name.empty?
328+
raise InvalidPayload, "expected a reminder handle returned by schedule"
329+
end
330+
331+
name
332+
end
333+
334+
# The view is the committed schedule with this turn's staged intents applied
335+
# in order, so a read agrees with what the commit will leave behind.
336+
# @rbs () -> Hash[String, ReminderStatus]
337+
def reminder_view
338+
reminder_intents.each_with_object(committed_reminders) do |intent, view|
339+
apply_reminder_intent(view, intent)
340+
end
341+
end
342+
343+
# @rbs () -> Hash[String, ReminderStatus]
344+
def committed_reminders
345+
return {} unless instance_id
346+
347+
Reminder.where(instance_id:).where.not(status: "completed").each_with_object({}) do |row, view|
348+
view[row.name] = reminder_status(
349+
name: row.name,
350+
operation: row.operation,
351+
next_run_at: row.next_run_at,
352+
interval_seconds: row.interval_seconds,
353+
missed_policy: row.missed_policy,
354+
occurrence: row.occurrence,
355+
status: row.status
356+
)
357+
end
358+
end
359+
360+
# @rbs (Hash[String, ReminderStatus], untyped) -> void
361+
def apply_reminder_intent(view, intent)
362+
return view.delete_if { |_name, status| status.operation == intent.operation } if intent.is_a?(UnscheduleAllIntent)
363+
return view.delete(intent.name) if intent.is_a?(UnscheduleIntent)
364+
365+
view[intent.name] = reminder_status(
366+
name: intent.name,
367+
operation: intent.operation,
368+
next_run_at: intent.at,
369+
interval_seconds: intent.interval_seconds,
370+
missed_policy: intent.missed_policy,
371+
occurrence: view[intent.name]&.occurrence || 0,
372+
status: "scheduled"
373+
)
374+
end
375+
376+
# @rbs (name: String, operation: String, next_run_at: Time?, interval_seconds: untyped, missed_policy: String, occurrence: Integer, status: String) -> ReminderStatus
377+
def reminder_status(name:, operation:, next_run_at:, interval_seconds:, missed_policy:, occurrence:, status:)
378+
ReminderStatus.new(
379+
name:,
380+
operation:,
381+
key: reminder_key_of(name:, operation:),
382+
next_run_at:,
383+
interval_seconds: interval_seconds&.to_f,
384+
missed_policy:,
385+
occurrence:,
386+
status:,
387+
handle: { REMINDER_HANDLE_KEY => name }
388+
)
389+
end
390+
391+
# @rbs (name: String, operation: String) -> String?
392+
def reminder_key_of(name:, operation:)
393+
return nil if name == operation
394+
395+
name.delete_prefix("#{operation}#{REMINDER_KEY_SEPARATOR}")
396+
end
397+
272398
# @rbs ((String | Symbol | Integer)?) -> String?
273399
def validated_reminder_key(key)
274400
return nil if key.nil?

‎lib/solid_objects/actor_snapshot.rb‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,8 @@ def build_actor
6262
end
6363
actor_class.new(
6464
actor_id: reference.actor_id,
65-
state: State.new(actor_class.definition.state_definition, state_data)
65+
state: State.new(actor_class.definition.state_definition, state_data),
66+
instance_id: @instance&.id
6667
)
6768
end
6869
end

‎lib/solid_objects/context.rb‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ module SolidObjects
55

66
module Context
77
STORAGE_KEY = :solid_objects_context
8-
Frame = Data.define(:actor, :message, :authorization_context)
8+
Frame = Data.define(:actor, :message, :authorization_context, :instance_id)
99

1010
class << self
1111
# @rbs () -> Frame?
@@ -28,10 +28,16 @@ def authorization_context
2828
current&.authorization_context
2929
end
3030

31-
# @rbs (actor: Actor?, message: MessageContext?, authorization_context: untyped) { () -> untyped } -> untyped
32-
def with(actor:, message:, authorization_context: nil)
31+
# @rbs () -> Integer?
32+
def current_instance_id
33+
current&.instance_id
34+
end
35+
36+
# @rbs (actor: Actor?, message: MessageContext?, ?authorization_context: untyped, ?instance_id: Integer?) { () -> untyped } -> untyped
37+
def with(actor:, message:, authorization_context: nil, instance_id: nil)
3338
previous = current
34-
ActiveSupport::IsolatedExecutionState[STORAGE_KEY] = Frame.new(actor:, message:, authorization_context:)
39+
ActiveSupport::IsolatedExecutionState[STORAGE_KEY] =
40+
Frame.new(actor:, message:, authorization_context:, instance_id:)
3541
yield
3642
ensure
3743
ActiveSupport::IsolatedExecutionState[STORAGE_KEY] = previous

‎lib/solid_objects/executor.rb‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,8 @@ def enqueue_effects(message:, instance:, intents:)
280280
# @rbs (Instance, Array[Actor::ReminderIntent]) -> Array[Hash[Symbol, untyped]]
281281
def schedule_reminders(instance, intents)
282282
intents.filter_map do |intent|
283+
next cancel_reminder(instance, intent) unless intent.is_a?(Actor::ReminderIntent)
284+
283285
reminder = Reminder.find_or_initialize_by(instance:, name: intent.name)
284286
previous_run_at = reminder.next_run_at
285287
reminder.assign_attributes(
@@ -300,6 +302,21 @@ def schedule_reminders(instance, intents)
300302
end
301303
end
302304

305+
# A cancel reports nothing, because a reminder that no longer exists did not
306+
# move. Deleting the row rather than marking it keeps a later schedule of the
307+
# same name free of a tombstone.
308+
# @rbs (Instance, Actor::UnscheduleIntent | Actor::UnscheduleAllIntent) -> nil
309+
def cancel_reminder(instance, intent)
310+
scope = Reminder.where(instance:)
311+
scope = if intent.is_a?(Actor::UnscheduleAllIntent)
312+
scope.where(operation: intent.operation)
313+
else
314+
scope.where(name: intent.name)
315+
end
316+
scope.delete_all
317+
nil
318+
end
319+
303320
# Arguments are omitted deliberately: a reminder carries application data
304321
# and this event exists to be logged.
305322
# @rbs (Reminder, Time?) -> Hash[Symbol, untyped]?

‎sig/generated/lib/solid_objects/activation.rbs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ module SolidObjects
1010

1111
@actor_class: Class
1212

13+
@instance_id: Integer
14+
15+
@actor_id: String
16+
1317
@lease: Lease
1418

1519
attr_reader lease: untyped
@@ -59,6 +63,9 @@ module SolidObjects
5963
# @rbs (Instance) -> Actor
6064
def build_actor: (Instance) -> Actor
6165

66+
# @rbs (Hash[String, untyped]) -> Actor
67+
def new_actor: (Hash[String, untyped]) -> Actor
68+
6269
# @rbs () -> void
6370
def release_lease: () -> void
6471

0 commit comments

Comments
 (0)