Skip to content

feat: cancel and read actor reminders - #75

Merged
cardmagic merged 7 commits into
mainfrom
feat/reminder-cancellation
Sep 22, 2026
Merged

cardmagic merged 7 commits into
mainfrom
feat/reminder-cancellation

Conversation

@cardmagic

@cardmagic cardmagic commented Sep 21, 2026

Copy link
Copy Markdown
Owner

Closes #71.

Why

Actor#schedule accepts every:, so an actor could start a repeating alarm.
Nothing stopped one. A search of lib/ and app/ for cancel or unschedule
returned no method, so the only way to stop a recurring reminder was to delete
the row by hand or destroy the actor. An actor also could not read its own
schedule.

What changed

class Subscription < SolidObjects::Actor
  actor_type "subscriptions"

  attribute :status, default: "trialing"
  attribute :chase, default: nil

  def start_trial
    schedule(at: 14.days.from_now).trial_expired
  end

  def convert_to_paid
    self.status = "active"
    unschedule(:trial_expired)
    self.chase = schedule(at: 30.days.from_now, every: 30.days).charge_renewal
  end

  def cancelled
    self.status = "cancelled"
    unschedule(chase)
  end

  # convert_to_paid cancels :trial_expired because that alarm is already armed
  # and would otherwise mark a paying subscription expired. It then arms
  # :charge_renewal, which cancelled cancels through the stored handle.

  def next_charge_at
    reminder(:charge_renewal)&.next_run_at
  end

  def trial_expired
    self.status = "expired"
  end

  def charge_renewal
  end
end

unschedule(chase) and unschedule(:charge_renewal) cancel the same alarm.
Prefer the handle when the actor already stored one, because it cannot drift
from the name that armed the reminder.

A keyed alarm cancels by the key that armed it, and unschedule_all cancels
every key of one operation:

class Shipment < SolidObjects::Actor
  actor_type "shipments"

  def dispatch(carrier_ids:)
    carrier_ids.each { |id| schedule(at: 1.day.from_now, key: id).chase_carrier(carrier_id: id) }
  end

  def shipped(carrier_id:)
    unschedule(:chase_carrier, key: carrier_id)
  end

  def stop_chasing
    unschedule_all(:chase_carrier)
  end

  def chase_carrier(carrier_id:)
  end
end

unschedule and unschedule_all refuse an operation the actor does not
declare, raising the UnknownMessage that schedule already raises. A typo
cancelled nothing quietly before, which is the failure this feature exists to
prevent. A handle skips that check, because schedule validated the operation
that produced it.

  • schedule returns { "reminder_name" => "charge_renewal" }.
  • unschedule(operation, key: nil) cancels one reminder. It also accepts a
    handle.
  • unschedule_all(operation) cancels every key of one operation.
  • reminder returns a ReminderStatus or nil. reminders lists every key of
    one operation.

Both cancels stage an intent beside the schedule intents, so they apply in call
order and commit with the state change that decided them. A turn that raises
cancels nothing. A cancel deletes the row rather than marking it, so a later
schedule of the same name meets no tombstone.

unschedule_all filters on the operation column, so it needs no prefix match
against composed names.

The handle is a Hash on purpose

emit already returns { "effect_id" => String }, declared in sig/public/.
A reminder handle takes the same form and gets the same treatment, so it
serialises into actor state and still cancels after a deactivation. Orleans
documents that its own reminder handle cannot survive an activation, which is
why that API cancels by name alone. Here the handle is a value, not a
registration, so it can.

Reads agree with the commit

reminder starts from the committed rows and applies the intents staged so far.
An actor that schedules and then reads in one turn sees the schedule it just
staged, and one that cancels and then reads sees it gone. The actor reaches its
own rows through the instance id now carried on the context frame, which had one
caller.

Behaviour change

schedule(...).operation returned nil. It now returns the handle. This is 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.

One existing test asserted the old contract and now asserts the handle:
fluent_dispatch_test.rb, "fluent schedule persists its message arguments and
recurrence options". Its assert_nil was incidental to a persistence test.

One correction to the issue

#71 says unschedule returns true or false. It returns nil. A cancel is
staged, not applied, so at call time there is nothing truthful to report about a
row that the commit has not yet touched. Reporting existence would have meant a
read whose answer could be stale by the time it committed. reminder answers
"does this exist" directly, and unschedule matches commit_action and
request_effect_recovery in returning nil. The issue is updated.

Tests

test/integration/reminder_cancellation_test.rb is new, 18 tests. 17 of the 18
fail with lib/ reverted to main, confirmed by reverting.

Covered: cancel stops a one-shot and a recurring reminder; a recurring reminder
that cancels itself fires once; cancelling an absent reminder raises nothing; a
raising turn cancels nothing; cancel then schedule in one turn leaves one row at
the new time; cancel by handle equals cancel by name; a handle stored in state
cancels later; a malformed handle raises InvalidPayload; a handle passed with a
key raises ArgumentError; keyed cancel spares siblings; unschedule_all spares
other operations; and five inspection cases including reads that see intents
staged earlier in the same turn.

Validation

bundle exec rake passes. Steep reports no type error, Brakeman no warning.

Backend Result
SQLite 683 runs, 0 failures, 24 skips
PostgreSQL 18 683 runs, 0 failures, 16 skips
MySQL 8.4, mysql2 683 runs, 0 failures, 32 skips
MySQL 8.4, Trilogy 683 runs, 0 failures, 32 skips

Skip counts match main on every backend.

Tried in a real application

A throwaway Rails 8.1 app installed this revision by path, ran the install
generator and the migrations, and exercised the feature two ways.

Direct calls covered the handle, the row, reading, cancel by name, cancel by
handle, cancelling nothing, keyed cancel, unschedule_all, and a malformed
handle reaching the dead letter as SolidObjects::InvalidPayload.

A separate bundle exec solid_objects start process then ran the same feature
end to end, which is the part worth stating: a recurring reminder fired
repeatedly in that runtime, reminder() returned a full ReminderStatus from
inside it, unschedule removed the row, and the firings stopped and stayed
stopped. That is the load contract this change could have broken, because actor
code now reaches SolidObjects::Reminder, and it holds.

bin/rails solid_objects:doctor reports PASS for configuration, schema,
database server, live runtime roles, and the synchronous round trip.

Compatibility

No migration. One new public type, reminder_handle, in
sig/public/reminder_payload.rbs, mirroring effect_handle.

A recurring reminder could be started and never stopped. schedule now
returns a durable handle, and unschedule removes one reminder by
operation and optional key, or by that handle. unschedule_all removes
every key of one operation.

Both cancels stage an intent beside the schedule intents, so they apply
in call order and commit with the state change that decided them. A turn
that raises cancels nothing. A cancel deletes the row rather than
marking it, so a later schedule of the same name meets no tombstone.

reminder and reminders read the schedule. The read starts from the
committed rows and applies the intents staged so far, so it agrees with
what the commit will write rather than with what the turn began with.
The actor reaches its own rows through the instance id now carried on
the context frame.

The handle is a plain Hash, as emit already returns, so it serialises
into actor state and still cancels after a deactivation. An Orleans
reminder handle cannot, which is why that API cancels by name alone.

schedule returned nil before. An operation that ends with schedule and
relies on an implicit nil result should return nil explicitly, as emit
required in 0.15.0.
@greptile-apps

greptile-apps Bot commented Sep 21, 2026

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

The PR appears safe to merge; no actionable new issue or outstanding previous finding remains.

Summary

This PR adds transactional reminder cancellation and inspection to the actor API.

  • schedule now returns a durable reminder handle.
  • unschedule and unschedule_all stage cancellation intents that commit with actor state.
  • reminder and reminders combine committed rows with staged intents to provide turn-consistent reads.
  • Actor construction and restoration retain durable instance identity for reminder reads in handlers, hooks, snapshots, and observables.
  • Integration coverage includes keyed reminders, recurring cancellation, rollback after failed turns, stored handles, lifecycle callbacks, and scheduler races.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A["Actor operation"] --> B{"Reminder action"}
  B -->|schedule| C["Schedule intent"]
  B -->|unschedule| D["Single-cancel intent"]
  B -->|unschedule_all| E["Operation-cancel intent"]
  C --> F["Ordered intent view"]
  D --> F
  E --> F
  G["Committed reminder rows"] --> F
  F --> H["reminder / reminders"]
  C --> I["Successful actor-turn commit"]
  D --> I
  E --> I
  I --> J["Create, update, or delete reminder rows"]
  J --> K["Reminder scheduler"]
  K --> L["Durable actor message"]
Loading

Reviews (7) · Last reviewed commit: "fix: do not report a spent reminder as a..."

Comment thread lib/solid_objects/actor.rb
reminder and reminders resolved the instance through the context frame,
which only message and query dispatch establish. An activation hook, a
deactivation hook, and observable evaluation all run outside it, so a
read there reported an armed reminder as absent. A hook that checks
before scheduling would have re-armed one that already existed.

An actor belongs to one instance for its whole life, so it now carries
that instance id from construction instead of reading an ambient frame.
Activation passes the row it already loaded, and a snapshot passes the
one it looked up. The context frame goes back to what it was.

Correct the contract as well. It said an occurrence already claimed by
the scheduler still fires. A cancel cannot recall an occurrence the
scheduler already turned into a message, but it does pre-empt one that
was claimed and not yet enqueued, which the scheduler already handles by
finding no row and returning.
@cardmagic

Copy link
Copy Markdown
Owner Author

Fixed in a814d46.

reminder and reminders resolved the instance through the context frame, which only message and query dispatch establish. Activation#initialize calls actor.activate and Activation#deactivate calls actor.deactivate outside any frame, and Executor#call reads observable_values before invoke_actor opens one. A read from any of those reported an armed reminder as absent, which is the worst kind of wrong answer here because a hook that checks before scheduling would silently re-arm.

Rather than widen the frame, the actor now carries its instance id from construction. An actor belongs to one instance for its whole life, so the ambient lookup was the wrong mechanism. Activation#build_actor passes the row it already loaded and ActorSnapshot#build_actor passes the one it looked up, and Context::Frame goes back to what it was, which also shrinks the diff.

Two tests cover it, and both fail against the previous commit: an on_activate hook reads the schedule, and an observable evaluated through ActorSnapshot reads it.

A second correction the finding led me to. The contract said an occurrence already claimed by the scheduler still fires. That is wrong in a window I had not tested. ReminderScheduler claims and commits, then enqueue reloads the row; a cancel landing between those finds no row, and next unless locked_reminder returns. So a cancel cannot recall an occurrence that is already a message, but it does pre-empt a claimed one that has not been enqueued. The roadmap and changelog now say that, and a test pins it by claiming, cancelling, and asserting enqueue returns nil without raising.

Validated on all four backends, 686 runs, 0 failures, skip counts matching main: SQLite 35, PostgreSQL 27, MySQL 43 and 43. rake exits 0 with Steep and Brakeman clean.

@cardmagic

Copy link
Copy Markdown
Owner Author

@greptileai review

Comment thread lib/solid_objects/actor.rb
restore_state rebuilt the actor after a rejected or failed turn without
the instance id, so the activation kept draining with an actor that read
every committed reminder as absent. The activation caches that actor, so
one failed turn poisoned every later read in the same pass.

The three construction sites were the cause, so there is now one.
Activation holds the actor id and the instance id from the start, and
both build_actor and restore_state go through new_actor. A fourth site
cannot forget what it never passes.
@cardmagic

Copy link
Copy Markdown
Owner Author

Fixed in d12b2e2.

Valid, and the consequence was wider than one read. Activation caches the actor, so a single rejected or failed turn left every later read in that pass seeing an empty schedule, not just the next one.

The cause was three construction sites, so there is now one. Activation holds the actor id and instance id from the start, and both build_actor and restore_state go through new_actor. A fourth site cannot forget an argument it never passes.

The regression test enqueues a failing message and a reading message so both run in one worker pass, which is what makes the cached activation the one that serves the read. It fails against the previous commit with Expected: "ping", Actual: nil.

687 runs, 0 failures on SQLite, PostgreSQL 18, mysql2 and Trilogy, skip counts unchanged. rake exits 0.

@cardmagic

Copy link
Copy Markdown
Owner Author

@greptileai review

schedule already raised UnknownMessage for an operation the actor does
not declare, because OperationDispatcher checks it. unschedule and
unschedule_all took any symbol, composed a name from it, and deleted
nothing. A typo cancelled quietly and left a recurring reminder running,
which is the failure this feature exists to prevent.

Both now check the operation against the declared messages and raise the
same error schedule raises. A handle skips the check, because schedule
validated the operation that produced it.
@cardmagic

Copy link
Copy Markdown
Owner Author

Added the matching operation-name check.

schedule already raised UnknownMessage for an operation the actor does not declare, because OperationDispatcher checks it. unschedule and unschedule_all took any symbol, composed a name from it, and deleted nothing, so a typo cancelled quietly and left a recurring reminder running. Both now check against the declared messages and raise the same error. A handle skips the check, because schedule validated the operation that produced it.

The test asserts the durable outcome rather than the raised error, since a direct caller sees MessageFailed: two dead letters, both SolidObjects::UnknownMessage, and the reminder still armed.

688 runs, 0 failures on SQLite, PostgreSQL 18, mysql2 and Trilogy, skip counts unchanged. rake exits 0 with Steep and Brakeman clean.

@cardmagic

Copy link
Copy Markdown
Owner Author

@greptileai review

unschedule already refused an operation the actor does not declare, but
reminder and reminders did not. A typo returned nil and an empty array,
so a caller asking whether an alarm was armed got told no rather than
told it had asked the wrong question. Both now raise the UnknownMessage
that schedule and unschedule raise.

Found by auditing the Ruby and JavaScript surfaces against each other:
the JavaScript reader already validated, and this side did not.
@cardmagic

Copy link
Copy Markdown
Owner Author

Audited the two implementations against each other. Three gaps, two of them real bugs on this side, both now fixed.

reminder and reminders did not validate the operation. unschedule refused an operation the actor does not declare, but the readers did not, so reminder(:pingg) returned nil and reminders(:pingg) returned []. A caller asking whether an alarm was armed got told no, rather than told it had asked the wrong question. Confirmed with a probe before fixing. Both now raise UnknownMessage, and a test asserts two dead letters rather than two silent answers. The JavaScript reader already validated.

Reading from an observable is supported here and was not on the JavaScript side; that is fixed there rather than removed here.

One difference stays, and it is principled. ReminderStatus here carries occurrence, which the reminders table tracks. The JavaScript port runs on Durable Objects as well, and that engine tracks no occurrence at all, so reporting it for one backend only would be worse than leaving it out. Everything else lines up: handle return, cancel by name, key, or handle, unschedule_all, reading one or many, staged-intent merging, and the claim window semantics.

689 runs, 0 failures on SQLite, PostgreSQL 18 and mysql2, skip counts unchanged. rake exits 0.

@cardmagic

Copy link
Copy Markdown
Owner Author

@greptileai review

A one-shot keeps its row after it fires, as completed, and the view
returned it. A next-run lookup reported an old time rather than nothing,
and an existence check refused to re-arm an alarm that could never fire
again.

Found by auditing against the JavaScript port, where the same defect was
reported. Both sides drop completed rows now.
@cardmagic

Copy link
Copy Markdown
Owner Author

Fixed a defect here that review found on the JavaScript side.

A one-shot keeps its row after it fires, with status completed, and the view returned it. I confirmed it with a probe before changing anything: after the alarm fired, reminder(:ping) returned completed@1790057303, a time already in the past. So next_charge_at reported an old timestamp rather than nothing, and an existence check would have refused to re-arm an alarm that can never fire again.

committed_reminders now excludes completed rows. Paused rows stay, because a paused reminder still exists and status tells the caller it will not fire until an operator resumes it.

The regression test arms a due one-shot, reads it, runs the scheduler, and reads again. It fails against the previous commit.

690 runs, 0 failures on SQLite, PostgreSQL 18 and mysql2, skip counts unchanged. rake exits 0.

@cardmagic

Copy link
Copy Markdown
Owner Author

@greptileai review

1 similar comment
@cardmagic

Copy link
Copy Markdown
Owner Author

@greptileai review

@cardmagic
cardmagic merged commit dde241e into main Sep 22, 2026
40 checks passed
@cardmagic
cardmagic deleted the feat/reminder-cancellation branch September 22, 2026 07:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Reminder cancellation and inspection

1 participant