Problem
Actor#schedule creates recurring reminders. Nothing cancels them.
def schedule(at:, every: nil, missed: :latest, key: nil)
lib/solid_objects/actor.rb:243 accepts every:, so an actor can start a
repeating alarm. A search of lib/ and app/ for cancel or unschedule
returns no method. The only way to stop a repeating reminder today is to delete
the row by hand, or to destroy the actor.
An actor also cannot read its own schedule. It cannot ask whether an alarm
exists, or when the alarm next runs.
Prior art
Orleans
cancels with UnregisterReminder, and it reads with GetReminder(name) and
GetReminders(). Orleans makes one point that shapes the design here: an
IGrainReminder handle is not guaranteed to be valid beyond the activation that
created it, so a caller that wants to cancel after a reactivation must first
fetch a fresh handle by name.
Solid Objects can avoid that limitation rather than inherit it. See
Handles below.
Cloudflare Durable Objects
pairs setAlarm with deleteAlarm and getAlarm. One object holds one alarm,
so Cloudflare pushes multi-alarm scheduling into application storage. Solid
Objects is already past that limit because of reminder keys. The part worth
copying is that a read of the pending alarm is part of the public surface, not a
debugging tool.
Proposed API
Cancellation is an intent, like schedule. The executor applies it inside the
fenced transaction, so a cancel commits atomically with the state change that
decided it.
class Subscription < SolidObjects::Actor
actor_type "subscriptions"
attribute :status, default: "trialing"
def start_trial
self.status = "trialing"
schedule(at: 14.days.from_now).trial_expired
end
def convert_to_paid
self.status = "active"
unschedule(:trial_expired) # cancels the alarm
schedule(at: 30.days.from_now, every: 30.days).charge_renewal
end
def cancel_immediately
self.status = "cancelled"
unschedule(:charge_renewal) # stops the recurring alarm
end
end
Keyed reminders cancel by the same key that scheduled them.
def item_shipped(order_id:)
unschedule(:chase_carrier, key: order_id)
end
def stop_chasing_everything
unschedule_all(:chase_carrier) # every key of one operation
end
Handles
schedule returns a reminder handle, and unschedule accepts one in place of
an operation and key.
class Shipment < SolidObjects::Actor
actor_type "shipments"
attribute :chase, default: nil
def dispatch(carrier_id:)
self.chase = schedule(at: 2.days.from_now, every: 1.day, key: carrier_id)
.chase_carrier(carrier_id:)
end
def delivered
unschedule(chase) # cancel by handle
self.chase = nil
end
end
This mirrors emit, which already returns a handle that request_effect_recovery
consumes:
# lib/solid_objects/actor.rb
{ "effect_id" => effect_id }
# sig/public/effect_payload.rbs
type effect_handle = { "effect_id" => String }
A reminder handle takes the same form, and gets the same public type:
# sig/public/reminder_payload.rbs
type reminder_handle = { "reminder_name" => String }
{ "reminder_name" => "chase_carrier:c-42" } is the composed name that
reminder_name already builds, so a handle adds no new identity. It is the
existing unique key of idx_so_reminders_name, wrapped in the shape this
project already uses for handles.
Validation mirrors request_effect_recovery:
def unschedule(operation_or_handle, key: nil)
return unschedule_handle(operation_or_handle) if operation_or_handle.is_a?(Hash)
unschedule_name(reminder_name(operation: operation_or_handle, key: validated_reminder_key(key)))
end
def unschedule_handle(handle)
name = handle["reminder_name"]
unless name.is_a?(String) && !name.empty?
raise InvalidPayload, "expected a reminder handle returned by schedule"
end
unschedule_name(name)
end
Why a handle works here and not in Orleans
An Orleans handle is a registration reference, so it dies with the activation.
This handle is a plain Hash with string keys, which is exactly why emit returns
one. It serialises into actor state, so an actor can store it, deactivate,
reactivate, and cancel with the same value days later. The Shipment example
above does that: chase is a declared attribute, so the handle is durable state,
not a transient object.
That also means a handle is not a capability. It names a reminder of the actor
that holds it, and unschedule only ever looks within the current instance, so a
handle copied between actors resolves to nothing.
Compatibility
schedule(...).operation returns nil today. Returning a handle repeats the
change emit made in 0.15.0:
Return a stable EffectHandle from every emit. Wrappers must return it;
operations relying on an implicit nil result should return nil explicitly.
The same note applies to schedule wrappers, and it belongs in the same major
release.
Inspection
Inspection reads the actor's own schedule.
def next_charge_at
reminder(:charge_renewal)&.next_run_at
end
def pending_chases
reminders(:chase_carrier).map(&:key)
end
reminder also accepts a handle, so a stored handle answers both questions:
def chase_due_at
reminder(chase)&.next_run_at
end
reminder returns nil or a frozen value object. It does not return an
Active Record row, because a reminder row is runtime state that an actor must
not mutate directly.
ReminderStatus = Data.define(
:name, :operation, :key, :next_run_at, :interval_seconds,
:missed_policy, :occurrence, :status, :handle
)
Data.define rather than Struct, because this library already uses it for
every value object and uses Struct for none. Serialization::Dumped,
ActorDefinition::Handler, State::Attribute, and every intent are all
Data.define. It is also immutable by default, which is what a read of runtime
state should be.
handle closes the loop: a reminder found by inspection can be cancelled
without rebuilding its name.
Semantics
unschedule is idempotent and returns nil. Cancelling an absent reminder
is not an error. It cannot report whether a row existed, because it stages an
intent rather than applying one, and an existence check made at call time
could be stale by the time the turn commits. reminder answers that question
directly. This matches commit_action and request_effect_recovery, which
also return nil.
unschedule inside a turn stages an intent. It takes effect at commit. A turn
that raises cancels nothing, which matches schedule.
unschedule then schedule in one turn, for one name, leaves the reminder
scheduled. Intents apply in order.
- A handle and its operation and key name the same reminder. Cancelling by
either is the same operation.
- A malformed handle raises
InvalidPayload, as a malformed effect handle does.
- A handle for a reminder that no longer exists cancels nothing and does not
raise, because an actor that stored a handle and later reactivated has no
cheaper way to learn the reminder already fired.
- A reminder already claimed by the scheduler still fires that occurrence. The
cancel removes later occurrences. This matches the pause semantics the
dashboard already documents, where a pass in flight finishes its turn.
reminder and reminders read the committed rows for the current
incarnation. A destroyed and recreated actor reads an empty schedule.
Schema
No migration. status already carries scheduled, and idx_so_reminders_name
already makes the lookup a unique index hit. Cancellation deletes the row rather
than setting a cancelled status, so a later schedule of the same name does
not collide with a tombstone.
Tests
- A cancelled non-recurring reminder does not fire.
- A cancelled recurring reminder stops after the cancel and does not fire again.
unschedule of an absent name raises nothing and cancels nothing.
- A turn that raises after
unschedule leaves the reminder scheduled.
unschedule then schedule in one turn leaves one row, with the new time.
- A reminder claimed before the cancel fires once, then stops.
- Keyed cancel removes one key and leaves its siblings.
unschedule_all removes every key of one operation and leaves other
operations alone.
- Inspection reports the next run time and the interval for a recurring
reminder, and reports nothing after a destroy.
Handles:
schedule returns a handle whose reminder_name matches the composed name.
- Cancelling by handle and cancelling by operation and key remove the same row.
- A handle stored in actor state cancels correctly after a deactivation and a
reactivation, which is the case an Orleans handle cannot serve.
- A handle for an already fired reminder raises nothing and cancels nothing.
- A malformed handle raises
InvalidPayload.
- A handle from one actor instance cancels nothing in another.
ReminderStatus#handle round-trips through unschedule.
Out of scope
Rescheduling by relative offset, cron expressions, and a global reminder
browser in the dashboard. This issue adds cancellation and reading only.
Problem
Actor#schedulecreates recurring reminders. Nothing cancels them.lib/solid_objects/actor.rb:243acceptsevery:, so an actor can start arepeating alarm. A search of
lib/andapp/forcancelorunschedulereturns no method. The only way to stop a repeating reminder today is to delete
the row by hand, or to destroy the actor.
An actor also cannot read its own schedule. It cannot ask whether an alarm
exists, or when the alarm next runs.
Prior art
Orleans
cancels with
UnregisterReminder, and it reads withGetReminder(name)andGetReminders(). Orleans makes one point that shapes the design here: anIGrainReminderhandle is not guaranteed to be valid beyond the activation thatcreated it, so a caller that wants to cancel after a reactivation must first
fetch a fresh handle by name.
Solid Objects can avoid that limitation rather than inherit it. See
Handles below.
Cloudflare Durable Objects
pairs
setAlarmwithdeleteAlarmandgetAlarm. One object holds one alarm,so Cloudflare pushes multi-alarm scheduling into application storage. Solid
Objects is already past that limit because of reminder keys. The part worth
copying is that a read of the pending alarm is part of the public surface, not a
debugging tool.
Proposed API
Cancellation is an intent, like
schedule. The executor applies it inside thefenced transaction, so a cancel commits atomically with the state change that
decided it.
Keyed reminders cancel by the same key that scheduled them.
Handles
schedulereturns a reminder handle, andunscheduleaccepts one in place ofan operation and key.
This mirrors
emit, which already returns a handle thatrequest_effect_recoveryconsumes:
A reminder handle takes the same form, and gets the same public type:
{ "reminder_name" => "chase_carrier:c-42" }is the composed name thatreminder_namealready builds, so a handle adds no new identity. It is theexisting unique key of
idx_so_reminders_name, wrapped in the shape thisproject already uses for handles.
Validation mirrors
request_effect_recovery:Why a handle works here and not in Orleans
An Orleans handle is a registration reference, so it dies with the activation.
This handle is a plain Hash with string keys, which is exactly why
emitreturnsone. It serialises into actor state, so an actor can store it, deactivate,
reactivate, and cancel with the same value days later. The
Shipmentexampleabove does that:
chaseis a declared attribute, so the handle is durable state,not a transient object.
That also means a handle is not a capability. It names a reminder of the actor
that holds it, and
unscheduleonly ever looks within the current instance, so ahandle copied between actors resolves to nothing.
Compatibility
schedule(...).operationreturnsniltoday. Returning a handle repeats thechange
emitmade in 0.15.0:The same note applies to
schedulewrappers, and it belongs in the same majorrelease.
Inspection
Inspection reads the actor's own schedule.
reminderalso accepts a handle, so a stored handle answers both questions:reminderreturnsnilor a frozen value object. It does not return anActive Record row, because a reminder row is runtime state that an actor must
not mutate directly.
Data.definerather thanStruct, because this library already uses it forevery value object and uses
Structfor none.Serialization::Dumped,ActorDefinition::Handler,State::Attribute, and every intent are allData.define. It is also immutable by default, which is what a read of runtimestate should be.
handlecloses the loop: a reminder found by inspection can be cancelledwithout rebuilding its name.
Semantics
unscheduleis idempotent and returnsnil. Cancelling an absent reminderis not an error. It cannot report whether a row existed, because it stages an
intent rather than applying one, and an existence check made at call time
could be stale by the time the turn commits.
reminderanswers that questiondirectly. This matches
commit_actionandrequest_effect_recovery, whichalso return
nil.unscheduleinside a turn stages an intent. It takes effect at commit. A turnthat raises cancels nothing, which matches
schedule.unschedulethenschedulein one turn, for one name, leaves the reminderscheduled. Intents apply in order.
either is the same operation.
InvalidPayload, as a malformed effect handle does.raise, because an actor that stored a handle and later reactivated has no
cheaper way to learn the reminder already fired.
cancel removes later occurrences. This matches the pause semantics the
dashboard already documents, where a pass in flight finishes its turn.
reminderandremindersread the committed rows for the currentincarnation. A destroyed and recreated actor reads an empty schedule.
Schema
No migration.
statusalready carriesscheduled, andidx_so_reminders_namealready makes the lookup a unique index hit. Cancellation deletes the row rather
than setting a
cancelledstatus, so a laterscheduleof the same name doesnot collide with a tombstone.
Tests
unscheduleof an absent name raises nothing and cancels nothing.unscheduleleaves the reminder scheduled.unschedulethenschedulein one turn leaves one row, with the new time.unschedule_allremoves every key of one operation and leaves otheroperations alone.
reminder, and reports nothing after a destroy.
Handles:
schedulereturns a handle whosereminder_namematches the composed name.reactivation, which is the case an Orleans handle cannot serve.
InvalidPayload.ReminderStatus#handleround-trips throughunschedule.Out of scope
Rescheduling by relative offset, cron expressions, and a global reminder
browser in the dashboard. This issue adds cancellation and reading only.