feat: cancel and read actor reminders - #75
Conversation
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.
|
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.
|
Fixed in a814d46.
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. Two tests cover it, and both fail against the previous commit: an 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. Validated on all four backends, 686 runs, 0 failures, skip counts matching main: SQLite 35, PostgreSQL 27, MySQL 43 and 43. |
|
@greptileai review |
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.
|
Fixed in d12b2e2. Valid, and the consequence was wider than one read. The cause was three construction sites, so there is now one. 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 687 runs, 0 failures on SQLite, PostgreSQL 18, mysql2 and Trilogy, skip counts unchanged. |
|
@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.
|
Added the matching operation-name check.
The test asserts the durable outcome rather than the raised error, since a direct caller sees 688 runs, 0 failures on SQLite, PostgreSQL 18, mysql2 and Trilogy, skip counts unchanged. |
|
@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.
|
Audited the two implementations against each other. Three gaps, two of them real bugs on this side, both now fixed.
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. 689 runs, 0 failures on SQLite, PostgreSQL 18 and mysql2, skip counts unchanged. |
|
@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.
|
Fixed a defect here that review found on the JavaScript side. A one-shot keeps its row after it fires, with status
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. |
|
@greptileai review |
1 similar comment
|
@greptileai review |
…ation # Conflicts: # CHANGELOG.md
Closes #71.
Why
Actor#scheduleacceptsevery:, so an actor could start a repeating alarm.Nothing stopped one. A search of
lib/andapp/forcancelorunschedulereturned 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
unschedule(chase)andunschedule(: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_allcancelsevery key of one operation:
unscheduleandunschedule_allrefuse an operation the actor does notdeclare, raising the
UnknownMessagethatschedulealready raises. A typocancelled nothing quietly before, which is the failure this feature exists to
prevent. A handle skips that check, because
schedulevalidated the operationthat produced it.
schedulereturns{ "reminder_name" => "charge_renewal" }.unschedule(operation, key: nil)cancels one reminder. It also accepts ahandle.
unschedule_all(operation)cancels every key of one operation.reminderreturns aReminderStatusornil.reminderslists every key ofone 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_allfilters on theoperationcolumn, so it needs no prefix matchagainst composed names.
The handle is a Hash on purpose
emitalready returns{ "effect_id" => String }, declared insig/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
reminderstarts 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(...).operationreturnednil. It now returns the handle. This is thechange
emitmade in 0.15.0:One existing test asserted the old contract and now asserts the handle:
fluent_dispatch_test.rb, "fluent schedule persists its message arguments andrecurrence options". Its
assert_nilwas incidental to a persistence test.One correction to the issue
#71 says
unschedulereturnstrueorfalse. It returnsnil. A cancel isstaged, 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.
reminderanswers"does this exist" directly, and
unschedulematchescommit_actionandrequest_effect_recoveryin returningnil. The issue is updated.Tests
test/integration/reminder_cancellation_test.rbis new, 18 tests. 17 of the 18fail with
lib/reverted tomain, 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 akey raises
ArgumentError; keyed cancel spares siblings;unschedule_allsparesother operations; and five inspection cases including reads that see intents
staged earlier in the same turn.
Validation
bundle exec rakepasses. Steep reports no type error, Brakeman no warning.Skip counts match
mainon 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 malformedhandle reaching the dead letter as
SolidObjects::InvalidPayload.A separate
bundle exec solid_objects startprocess then ran the same featureend to end, which is the part worth stating: a recurring reminder fired
repeatedly in that runtime,
reminder()returned a fullReminderStatusfrominside it,
unscheduleremoved the row, and the firings stopped and stayedstopped. That is the load contract this change could have broken, because actor
code now reaches
SolidObjects::Reminder, and it holds.bin/rails solid_objects:doctorreportsPASSfor configuration, schema,database server, live runtime roles, and the synchronous round trip.
Compatibility
No migration. One new public type,
reminder_handle, insig/public/reminder_payload.rbs, mirroringeffect_handle.