Skip to content

Reminder cancellation and inspection #71

Description

@cardmagic

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions