From 44c1655c3bcd4d7bef2d5d4919d873b7a68a8214 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Mon, 21 Sep 2026 15:48:39 -0700 Subject: [PATCH 1/6] feat: cancel and read actor reminders 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. --- CHANGELOG.md | 13 + docs/roadmap.md | 7 + lib/solid_objects/actor.rb | 126 ++++++- lib/solid_objects/context.rb | 14 +- lib/solid_objects/executor.rb | 19 +- sig/generated/lib/solid_objects/actor.rbs | 86 +++++ sig/generated/lib/solid_objects/context.rbs | 17 +- sig/generated/lib/solid_objects/executor.rbs | 6 + sig/public/reminder_payload.rbs | 3 + test/integration/fluent_dispatch_test.rb | 4 +- .../integration/reminder_cancellation_test.rb | 337 ++++++++++++++++++ 11 files changed, 614 insertions(+), 18 deletions(-) create mode 100644 sig/public/reminder_payload.rbs create mode 100644 test/integration/reminder_cancellation_test.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index c87adbe..e3e040b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## Unreleased + +- Add reminder cancellation. `unschedule` removes one reminder by operation and + optional key, or by the handle `schedule` now returns. `unschedule_all` + removes every key of one operation. Both stage an intent, so a cancel commits + with the state change that decided it, and a turn that raises cancels nothing. +- Add reminder reading. `reminder` returns a `ReminderStatus` or `nil`, and + `reminders` lists every key of one operation. A read applies the intents + staged in the current turn, so it agrees with what the commit will write. +- `schedule` now returns a reminder handle instead of `nil`. An operation that + ends with `schedule` and relies on an implicit `nil` result should return + `nil` explicitly, as `emit` required in 0.15.0. + ## 0.15.2 - 2026-09-21 - Find the actor instance before the insert when an enqueue starts, and lock diff --git a/docs/roadmap.md b/docs/roadmap.md index cb42e67..25838ea 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -36,6 +36,13 @@ listed here while broken in that worker: the scheduler reached a constant the caller path happened to load, so reminders never fired in production and every in-process test still passed +- Reminder cancellation and reading. `schedule` returns a durable handle, + `unschedule` and `unschedule_all` cancel by name, key, or handle, and + `reminder` and `reminders` read the schedule. A cancel is an intent, so it + commits with the state change that decided it. A read applies the intents + staged so far, so it agrees with what the commit will leave behind. An + occurrence already claimed by the scheduler still fires; the cancel removes + later occurrences - Durable invalidation-only observable broadcasts by default, explicit `broadcast: :value` scalar Turbo replacement, keyed ERB components, signed component locals, and authorized replace or morph refresh. Default diff --git a/lib/solid_objects/actor.rb b/lib/solid_objects/actor.rb index 50f89b2..24297dc 100644 --- a/lib/solid_objects/actor.rb +++ b/lib/solid_objects/actor.rb @@ -10,7 +10,13 @@ class Actor REMINDER_NAME_LIMIT = 191 REMINDER_KEY_SEPARATOR = ":" + REMINDER_HANDLE_KEY = "reminder_name" + ReminderIntent = Data.define(:name, :operation, :at, :arguments, :interval_seconds, :missed_policy) + UnscheduleIntent = Data.define(:name) + UnscheduleAllIntent = Data.define(:operation) + ReminderStatus = Data.define(:name, :operation, :key, :next_run_at, :interval_seconds, + :missed_policy, :occurrence, :status, :handle) OutboundMessageIntent = Data.define(:actor_type, :actor_id, :operation, :arguments, :available_at, :idempotency_key) class << self @@ -255,20 +261,128 @@ def schedule(at:, every: nil, missed: :latest, key: nil) actor_type: self.class.actor_type, handlers: self.class.definition.messages ) do |operation, arguments| - ReminderIntent.new( - name: reminder_name(operation:, key: reminder_key), + name = reminder_name(operation:, key: reminder_key) + reminder_intents << ReminderIntent.new( + name:, operation: operation.to_s, at:, arguments: Serialization.dump(arguments), interval_seconds:, missed_policy: - ).tap do |intent| - reminder_intents << intent - end - nil + ) + { REMINDER_HANDLE_KEY => name } end end + # @rbs (Symbol | String | reminder_handle, ?key: (String | Symbol | Integer)?) -> nil + def unschedule(operation_or_handle, key: nil) + return unschedule_name(handle_name(operation_or_handle, key:)) if operation_or_handle.is_a?(Hash) + + unschedule_name(reminder_name(operation: operation_or_handle, key: validated_reminder_key(key))) + end + + # @rbs (Symbol | String) -> nil + def unschedule_all(operation) + reminder_intents << UnscheduleAllIntent.new(operation: operation.to_s) + nil + end + + # @rbs (Symbol | String | reminder_handle, ?key: (String | Symbol | Integer)?) -> ReminderStatus? + def reminder(operation_or_handle, key: nil) + return reminder_view[handle_name(operation_or_handle, key:)] if operation_or_handle.is_a?(Hash) + + reminder_view[reminder_name(operation: operation_or_handle, key: validated_reminder_key(key))] + end + + # @rbs (Symbol | String) -> Array[ReminderStatus] + def reminders(operation) + wanted = operation.to_s + reminder_view.each_value.select { |status| status.operation == wanted } + end + + # @rbs (String) -> nil + def unschedule_name(name) + reminder_intents << UnscheduleIntent.new(name:) + nil + end + + # @rbs (reminder_handle, key: untyped) -> String + def handle_name(handle, key:) + raise ArgumentError, "a reminder handle already names its key" unless key.nil? + + name = handle[REMINDER_HANDLE_KEY] + unless name.is_a?(String) && !name.empty? + raise InvalidPayload, "expected a reminder handle returned by schedule" + end + + name + end + + # The view is the committed schedule with this turn's staged intents applied + # in order, so a read agrees with what the commit will leave behind. + # @rbs () -> Hash[String, ReminderStatus] + def reminder_view + reminder_intents.each_with_object(committed_reminders) do |intent, view| + apply_reminder_intent(view, intent) + end + end + + # @rbs () -> Hash[String, ReminderStatus] + def committed_reminders + instance_id = Context.current_instance_id + return {} unless instance_id + + Reminder.where(instance_id:).each_with_object({}) do |row, view| + view[row.name] = reminder_status( + name: row.name, + operation: row.operation, + next_run_at: row.next_run_at, + interval_seconds: row.interval_seconds, + missed_policy: row.missed_policy, + occurrence: row.occurrence, + status: row.status + ) + end + end + + # @rbs (Hash[String, ReminderStatus], untyped) -> void + def apply_reminder_intent(view, intent) + return view.delete_if { |_name, status| status.operation == intent.operation } if intent.is_a?(UnscheduleAllIntent) + return view.delete(intent.name) if intent.is_a?(UnscheduleIntent) + + view[intent.name] = reminder_status( + name: intent.name, + operation: intent.operation, + next_run_at: intent.at, + interval_seconds: intent.interval_seconds, + missed_policy: intent.missed_policy, + occurrence: view[intent.name]&.occurrence || 0, + status: "scheduled" + ) + end + + # @rbs (name: String, operation: String, next_run_at: Time?, interval_seconds: untyped, missed_policy: String, occurrence: Integer, status: String) -> ReminderStatus + def reminder_status(name:, operation:, next_run_at:, interval_seconds:, missed_policy:, occurrence:, status:) + ReminderStatus.new( + name:, + operation:, + key: reminder_key_of(name:, operation:), + next_run_at:, + interval_seconds: interval_seconds&.to_f, + missed_policy:, + occurrence:, + status:, + handle: { REMINDER_HANDLE_KEY => name } + ) + end + + # @rbs (name: String, operation: String) -> String? + def reminder_key_of(name:, operation:) + return nil if name == operation + + name.delete_prefix("#{operation}#{REMINDER_KEY_SEPARATOR}") + end + # @rbs ((String | Symbol | Integer)?) -> String? def validated_reminder_key(key) return nil if key.nil? diff --git a/lib/solid_objects/context.rb b/lib/solid_objects/context.rb index e96ef14..49f8bec 100644 --- a/lib/solid_objects/context.rb +++ b/lib/solid_objects/context.rb @@ -5,7 +5,7 @@ module SolidObjects module Context STORAGE_KEY = :solid_objects_context - Frame = Data.define(:actor, :message, :authorization_context) + Frame = Data.define(:actor, :message, :authorization_context, :instance_id) class << self # @rbs () -> Frame? @@ -28,10 +28,16 @@ def authorization_context current&.authorization_context end - # @rbs (actor: Actor?, message: MessageContext?, authorization_context: untyped) { () -> untyped } -> untyped - def with(actor:, message:, authorization_context: nil) + # @rbs () -> Integer? + def current_instance_id + current&.instance_id + end + + # @rbs (actor: Actor?, message: MessageContext?, ?authorization_context: untyped, ?instance_id: Integer?) { () -> untyped } -> untyped + def with(actor:, message:, authorization_context: nil, instance_id: nil) previous = current - ActiveSupport::IsolatedExecutionState[STORAGE_KEY] = Frame.new(actor:, message:, authorization_context:) + ActiveSupport::IsolatedExecutionState[STORAGE_KEY] = + Frame.new(actor:, message:, authorization_context:, instance_id:) yield ensure ActiveSupport::IsolatedExecutionState[STORAGE_KEY] = previous diff --git a/lib/solid_objects/executor.rb b/lib/solid_objects/executor.rb index 553008e..8fe4ba7 100644 --- a/lib/solid_objects/executor.rb +++ b/lib/solid_objects/executor.rb @@ -61,7 +61,7 @@ def actor # @rbs (MessageContext) -> untyped def invoke_actor(message_context) - Context.with(actor:, message: message_context) do + Context.with(actor:, message: message_context, instance_id: activation.lease.instance_id) do actor.invoke(message.operation, message.arguments) end end @@ -280,6 +280,8 @@ def enqueue_effects(message:, instance:, intents:) # @rbs (Instance, Array[Actor::ReminderIntent]) -> Array[Hash[Symbol, untyped]] def schedule_reminders(instance, intents) intents.filter_map do |intent| + next cancel_reminder(instance, intent) unless intent.is_a?(Actor::ReminderIntent) + reminder = Reminder.find_or_initialize_by(instance:, name: intent.name) previous_run_at = reminder.next_run_at reminder.assign_attributes( @@ -300,6 +302,21 @@ def schedule_reminders(instance, intents) end end + # A cancel reports nothing, because a reminder that no longer exists did not + # move. Deleting the row rather than marking it keeps a later schedule of the + # same name free of a tombstone. + # @rbs (Instance, Actor::UnscheduleIntent | Actor::UnscheduleAllIntent) -> nil + def cancel_reminder(instance, intent) + scope = Reminder.where(instance:) + scope = if intent.is_a?(Actor::UnscheduleAllIntent) + scope.where(operation: intent.operation) + else + scope.where(name: intent.name) + end + scope.delete_all + nil + end + # Arguments are omitted deliberately: a reminder carries application data # and this event exists to be logged. # @rbs (Reminder, Time?) -> Hash[Symbol, untyped]? diff --git a/sig/generated/lib/solid_objects/actor.rbs b/sig/generated/lib/solid_objects/actor.rbs index e64d41c..5a09c5e 100644 --- a/sig/generated/lib/solid_objects/actor.rbs +++ b/sig/generated/lib/solid_objects/actor.rbs @@ -58,6 +58,8 @@ module SolidObjects REMINDER_KEY_SEPARATOR: ::String + REMINDER_HANDLE_KEY: ::String + class ReminderIntent < Data attr_reader name(): untyped @@ -79,6 +81,55 @@ module SolidObjects def members: () -> [ :name, :operation, :at, :arguments, :interval_seconds, :missed_policy ] end + class UnscheduleIntent < Data + attr_reader name(): untyped + + def self.new: (untyped name) -> instance + | (name: untyped) -> instance + + def self.members: () -> [ :name ] + + def members: () -> [ :name ] + end + + class UnscheduleAllIntent < Data + attr_reader operation(): untyped + + def self.new: (untyped operation) -> instance + | (operation: untyped) -> instance + + def self.members: () -> [ :operation ] + + def members: () -> [ :operation ] + end + + class ReminderStatus < Data + attr_reader name(): untyped + + attr_reader operation(): untyped + + attr_reader key(): untyped + + attr_reader next_run_at(): untyped + + attr_reader interval_seconds(): untyped + + attr_reader missed_policy(): untyped + + attr_reader occurrence(): untyped + + attr_reader status(): untyped + + attr_reader handle(): untyped + + def self.new: (untyped name, untyped operation, untyped key, untyped next_run_at, untyped interval_seconds, untyped missed_policy, untyped occurrence, untyped status, untyped handle) -> instance + | (name: untyped, operation: untyped, key: untyped, next_run_at: untyped, interval_seconds: untyped, missed_policy: untyped, occurrence: untyped, status: untyped, handle: untyped) -> instance + + def self.members: () -> [ :name, :operation, :key, :next_run_at, :interval_seconds, :missed_policy, :occurrence, :status, :handle ] + + def members: () -> [ :name, :operation, :key, :next_run_at, :interval_seconds, :missed_policy, :occurrence, :status, :handle ] + end + class OutboundMessageIntent < Data attr_reader actor_type(): untyped @@ -201,6 +252,41 @@ module SolidObjects # @rbs (at: Time, ?every: Numeric?, ?missed: Symbol | String, ?key: (String | Symbol | Integer)?) -> OperationDispatcher def schedule: (at: Time, ?every: Numeric?, ?missed: Symbol | String, ?key: (String | Symbol | Integer)?) -> OperationDispatcher + # @rbs (Symbol | String | reminder_handle, ?key: (String | Symbol | Integer)?) -> nil + def unschedule: (Symbol | String | reminder_handle, ?key: (String | Symbol | Integer)?) -> nil + + # @rbs (Symbol | String) -> nil + def unschedule_all: (Symbol | String) -> nil + + # @rbs (Symbol | String | reminder_handle, ?key: (String | Symbol | Integer)?) -> ReminderStatus? + def reminder: (Symbol | String | reminder_handle, ?key: (String | Symbol | Integer)?) -> ReminderStatus? + + # @rbs (Symbol | String) -> Array[ReminderStatus] + def reminders: (Symbol | String) -> Array[ReminderStatus] + + # @rbs (String) -> nil + def unschedule_name: (String) -> nil + + # @rbs (reminder_handle, key: untyped) -> String + def handle_name: (reminder_handle, key: untyped) -> String + + # The view is the committed schedule with this turn's staged intents applied + # in order, so a read agrees with what the commit will leave behind. + # @rbs () -> Hash[String, ReminderStatus] + def reminder_view: () -> Hash[String, ReminderStatus] + + # @rbs () -> Hash[String, ReminderStatus] + def committed_reminders: () -> Hash[String, ReminderStatus] + + # @rbs (Hash[String, ReminderStatus], untyped) -> void + def apply_reminder_intent: (Hash[String, ReminderStatus], untyped) -> void + + # @rbs (name: String, operation: String, next_run_at: Time?, interval_seconds: untyped, missed_policy: String, occurrence: Integer, status: String) -> ReminderStatus + def reminder_status: (name: String, operation: String, next_run_at: Time?, interval_seconds: untyped, missed_policy: String, occurrence: Integer, status: String) -> ReminderStatus + + # @rbs (name: String, operation: String) -> String? + def reminder_key_of: (name: String, operation: String) -> String? + # @rbs ((String | Symbol | Integer)?) -> String? def validated_reminder_key: ((String | Symbol | Integer)?) -> String? diff --git a/sig/generated/lib/solid_objects/context.rbs b/sig/generated/lib/solid_objects/context.rbs index 073f56b..793f0a6 100644 --- a/sig/generated/lib/solid_objects/context.rbs +++ b/sig/generated/lib/solid_objects/context.rbs @@ -30,12 +30,14 @@ module SolidObjects attr_reader authorization_context(): untyped - def self.new: (untyped actor, untyped message, untyped authorization_context) -> instance - | (actor: untyped, message: untyped, authorization_context: untyped) -> instance + attr_reader instance_id(): untyped - def self.members: () -> [ :actor, :message, :authorization_context ] + def self.new: (untyped actor, untyped message, untyped authorization_context, untyped instance_id) -> instance + | (actor: untyped, message: untyped, authorization_context: untyped, instance_id: untyped) -> instance - def members: () -> [ :actor, :message, :authorization_context ] + def self.members: () -> [ :actor, :message, :authorization_context, :instance_id ] + + def members: () -> [ :actor, :message, :authorization_context, :instance_id ] end # @rbs () -> Frame? @@ -50,7 +52,10 @@ module SolidObjects # @rbs () -> untyped def self.authorization_context: () -> untyped - # @rbs (actor: Actor?, message: MessageContext?, authorization_context: untyped) { () -> untyped } -> untyped - def self.with: (actor: Actor?, message: MessageContext?, authorization_context: untyped) { () -> untyped } -> untyped + # @rbs () -> Integer? + def self.current_instance_id: () -> Integer? + + # @rbs (actor: Actor?, message: MessageContext?, ?authorization_context: untyped, ?instance_id: Integer?) { () -> untyped } -> untyped + def self.with: (actor: Actor?, message: MessageContext?, ?authorization_context: untyped, ?instance_id: Integer?) { () -> untyped } -> untyped end end diff --git a/sig/generated/lib/solid_objects/executor.rbs b/sig/generated/lib/solid_objects/executor.rbs index 63e3ff7..98e4235 100644 --- a/sig/generated/lib/solid_objects/executor.rbs +++ b/sig/generated/lib/solid_objects/executor.rbs @@ -65,6 +65,12 @@ module SolidObjects # @rbs (Instance, Array[Actor::ReminderIntent]) -> Array[Hash[Symbol, untyped]] def schedule_reminders: (Instance, Array[Actor::ReminderIntent]) -> Array[Hash[Symbol, untyped]] + # A cancel reports nothing, because a reminder that no longer exists did not + # move. Deleting the row rather than marking it keeps a later schedule of the + # same name free of a tombstone. + # @rbs (Instance, Actor::UnscheduleIntent | Actor::UnscheduleAllIntent) -> nil + def cancel_reminder: (Instance, Actor::UnscheduleIntent | Actor::UnscheduleAllIntent) -> nil + # Arguments are omitted deliberately: a reminder carries application data # and this event exists to be logged. # @rbs (Reminder, Time?) -> Hash[Symbol, untyped]? diff --git a/sig/public/reminder_payload.rbs b/sig/public/reminder_payload.rbs new file mode 100644 index 0000000..484c325 --- /dev/null +++ b/sig/public/reminder_payload.rbs @@ -0,0 +1,3 @@ +module SolidObjects + type reminder_handle = { "reminder_name" => String } +end diff --git a/test/integration/fluent_dispatch_test.rb b/test/integration/fluent_dispatch_test.rb index 28b15f7..0c1c9c5 100644 --- a/test/integration/fluent_dispatch_test.rb +++ b/test/integration/fluent_dispatch_test.rb @@ -233,12 +233,14 @@ def evaluate(account_id:, at: nil, every: nil, missed: nil) test "fluent schedule persists its message arguments and recurrence options" do scheduled_at = 1.hour.from_now.change(usec: 0) - assert_nil SourceActor.ref("scheduler").configure_reminder( + handle = SourceActor.ref("scheduler").configure_reminder( at: scheduled_at.to_f, every: 3600, missed: :all, account_id: "account-1" ) + + assert_equal({ "reminder_name" => "evaluate" }, handle) reminder = SolidObjects::Reminder.find_by!(actor_id: "scheduler") assert_equal "evaluate", reminder.name diff --git a/test/integration/reminder_cancellation_test.rb b/test/integration/reminder_cancellation_test.rb new file mode 100644 index 0000000..5ba99b0 --- /dev/null +++ b/test/integration/reminder_cancellation_test.rb @@ -0,0 +1,337 @@ +# frozen_string_literal: true + +require "database_test_helper" + +class ReminderCancellationTest < ActiveSupport::TestCase + class TrialActor < SolidObjects::Actor + actor_type "cancel-trial" + + attribute :status, default: "trialing" + attribute :expirations, default: 0 + attribute :handle, default: nil + + def start_trial + self.handle = schedule(at: 1.hour.from_now).trial_expired + end + + def start_recurring + self.handle = schedule(at: 1.minute.ago, every: 60).trial_expired + end + + def convert + self.status = "active" + unschedule(:trial_expired) + end + + def convert_by_handle + self.status = "active" + unschedule(handle) + end + + def convert_then_reschedule + unschedule(:trial_expired) + schedule(at: 3.hours.from_now).trial_expired + end + + def convert_then_raise + unschedule(:trial_expired) + raise "turn failed" + end + + def convert_with_bad_handle + unschedule({ "not_a_reminder" => "x" }) + end + + def convert_with_handle_and_key + unschedule({ "reminder_name" => "trial_expired" }, key: "extra") + end + + def trial_expired + self.expirations += 1 + self.status = "expired" + end + + def stop_after_first + self.expirations += 1 + unschedule(:trial_expired) + end + end + + class ChaseActor < SolidObjects::Actor + actor_type "cancel-chase" + + attribute :chased, default: -> { [] } + + def chase_many(ids:) + ids.each { |id| schedule(at: 1.hour.from_now, key: id).chase_carrier(carrier_id: id) } + schedule(at: 1.hour.from_now).audit + 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:) + self.chased = chased + [ carrier_id ] + end + + def audit + end + end + + class InspectorActor < SolidObjects::Actor + actor_type "cancel-inspector" + + attribute :seen, default: nil + + def arm + schedule(at: Time.utc(2030, 1, 1), every: 90).ping + end + + def read_next_run + found = reminder(:ping) + self.seen = found && { + "name" => found.name, + "operation" => found.operation, + "next_run_at" => found.next_run_at.to_i, + "interval_seconds" => found.interval_seconds.to_f, + "handle" => found.handle + } + end + + def read_after_staged_schedule + schedule(at: Time.utc(2031, 1, 1)).ping + self.seen = { "next_run_at" => reminder(:ping).next_run_at.to_i } + end + + def read_after_staged_cancel + unschedule(:ping) + self.seen = { "present" => !reminder(:ping).nil? } + end + + def count_keyed + self.seen = { "count" => reminders(:ping).length } + end + + def arm_keyed + schedule(at: Time.utc(2030, 1, 1), key: "a").ping + schedule(at: Time.utc(2030, 1, 1), key: "b").ping + end + + def ping + end + end + + def drain + worker = SolidObjects::Worker.new + worker.run_until_idle + worker.stop + end + + def reminders_for(actor_type) + SolidObjects::Reminder.where(actor_type:).order(:name) + end + + def state_of(actor_type) + SolidObjects::Instance.find_by!(actor_type:).state + end + + test "schedule returns a reminder handle naming the reminder" do + TrialActor.ref("alice").async.start_trial + drain + + assert_equal({ "reminder_name" => "trial_expired" }, state_of("cancel-trial").fetch("handle")) + end + + test "a cancelled reminder does not fire" do + reference = TrialActor.ref("alice") + reference.async.start_trial + drain + reference.async.convert + drain + + assert_empty reminders_for("cancel-trial") + assert_equal 0, state_of("cancel-trial").fetch("expirations") + end + + test "a cancelled recurring reminder stops firing" do + reference = TrialActor.ref("alice") + reference.async.start_recurring + drain + scheduler = SolidObjects::ReminderScheduler.new + scheduler.run_once + drain + + assert_equal 1, state_of("cancel-trial").fetch("expirations") + + reference.async.convert + drain + + assert_empty reminders_for("cancel-trial") + assert_not scheduler.run_once, "no reminder should remain due" + ensure + scheduler&.stop + end + + test "a recurring reminder that cancels itself fires once" do + TrialActor.ref("alice").async.start_recurring + drain + # Replace the handler so the first firing cancels the schedule. + SolidObjects::Reminder.find_by!(actor_type: "cancel-trial").update!(operation: "stop_after_first") + scheduler = SolidObjects::ReminderScheduler.new + scheduler.run_once + drain + + assert_equal 1, state_of("cancel-trial").fetch("expirations") + assert_empty reminders_for("cancel-trial") + assert_not scheduler.run_once + ensure + scheduler&.stop + end + + test "cancelling an absent reminder raises nothing" do + TrialActor.ref("alice").async.convert + drain + + assert_equal "active", state_of("cancel-trial").fetch("status") + assert_empty reminders_for("cancel-trial") + end + + test "a turn that raises leaves the reminder scheduled" do + reference = TrialActor.ref("alice") + reference.async.start_trial + drain + reference.async.convert_then_raise + drain + + assert_equal 1, reminders_for("cancel-trial").count + end + + test "cancel then schedule in one turn leaves one reminder at the new time" do + reference = TrialActor.ref("alice") + reference.async.start_trial + drain + reference.async.convert_then_reschedule + drain + + reminders = reminders_for("cancel-trial") + assert_equal 1, reminders.count + assert_operator reminders.first.next_run_at, :>, 2.hours.from_now + end + + test "cancelling by handle removes the same reminder as cancelling by name" do + reference = TrialActor.ref("alice") + reference.async.start_trial + drain + reference.async.convert_by_handle + drain + + assert_empty reminders_for("cancel-trial") + end + + test "a handle stored in state cancels after a reactivation" do + reference = TrialActor.ref("alice") + reference.async.start_trial + drain + + assert_equal 1, reminders_for("cancel-trial").count + + reference.async.convert_by_handle + drain + + assert_empty reminders_for("cancel-trial") + end + + test "a malformed handle is rejected" do + SolidObjects.configuration.max_attempts = 1 + TrialActor.ref("alice").async.convert_with_bad_handle + drain + + dead_letter = SolidObjects::DeadLetter.find_by!(actor_type: "cancel-trial") + assert_equal "SolidObjects::InvalidPayload", dead_letter.exception_class + assert_match(/reminder handle/, dead_letter.exception_message) + end + + test "a handle passed with a key is refused" do + SolidObjects.configuration.max_attempts = 1 + TrialActor.ref("alice").async.convert_with_handle_and_key + drain + + assert_equal "ArgumentError", + SolidObjects::DeadLetter.find_by!(actor_type: "cancel-trial").exception_class + end + + test "a keyed cancel removes one key and leaves its siblings" do + reference = ChaseActor.ref("truck") + reference.async.chase_many(ids: %w[a b c]) + drain + reference.async.shipped(carrier_id: "b") + drain + + assert_equal %w[audit chase_carrier:a chase_carrier:c], reminders_for("cancel-chase").pluck(:name) + end + + test "unschedule_all removes every key of one operation" do + reference = ChaseActor.ref("truck") + reference.async.chase_many(ids: %w[a b c]) + drain + reference.async.stop_chasing + drain + + assert_equal %w[audit], reminders_for("cancel-chase").pluck(:name) + end + + test "inspection reports the next run time and interval" do + reference = InspectorActor.ref("one") + reference.async.arm + drain + reference.async.read_next_run + drain + + seen = state_of("cancel-inspector").fetch("seen") + assert_equal "ping", seen.fetch("name") + assert_equal "ping", seen.fetch("operation") + assert_equal Time.utc(2030, 1, 1).to_i, seen.fetch("next_run_at") + assert_in_delta 90.0, seen.fetch("interval_seconds"), 0.001 + assert_equal({ "reminder_name" => "ping" }, seen.fetch("handle")) + end + + test "inspection reports nothing for an unscheduled reminder" do + InspectorActor.ref("one").async.read_next_run + drain + + assert_nil state_of("cancel-inspector").fetch("seen") + end + + test "inspection sees a schedule staged earlier in the same turn" do + InspectorActor.ref("one").async.read_after_staged_schedule + drain + + assert_equal Time.utc(2031, 1, 1).to_i, + state_of("cancel-inspector").fetch("seen").fetch("next_run_at") + end + + test "inspection sees a cancel staged earlier in the same turn" do + reference = InspectorActor.ref("one") + reference.async.arm + drain + reference.async.read_after_staged_cancel + drain + + assert_equal false, state_of("cancel-inspector").fetch("seen").fetch("present") + end + + test "inspection lists every key of one operation" do + reference = InspectorActor.ref("one") + reference.async.arm_keyed + drain + reference.async.count_keyed + drain + + assert_equal 2, state_of("cancel-inspector").fetch("seen").fetch("count") + end +end From a814d466817ac1768741441bdca238b0b5bd174b Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Mon, 21 Sep 2026 21:21:47 -0700 Subject: [PATCH 2/6] fix: read reminders from every actor path 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. --- CHANGELOG.md | 7 ++- docs/roadmap.md | 7 ++- lib/solid_objects/activation.rb | 3 +- lib/solid_objects/actor.rb | 8 ++- lib/solid_objects/actor_snapshot.rb | 3 +- lib/solid_objects/executor.rb | 2 +- sig/generated/lib/solid_objects/actor.rbs | 6 +- .../integration/reminder_cancellation_test.rb | 60 +++++++++++++++++++ 8 files changed, 84 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3e040b..ced6903 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,12 @@ with the state change that decided it, and a turn that raises cancels nothing. - Add reminder reading. `reminder` returns a `ReminderStatus` or `nil`, and `reminders` lists every key of one operation. A read applies the intents - staged in the current turn, so it agrees with what the commit will write. + staged in the current turn, so it agrees with what the commit will write. An + actor reads its own schedule from every path, including activation hooks and + observables, because it carries its instance rather than reading an ambient + context that only message dispatch establishes. +- A cancel cannot recall an occurrence the scheduler already turned into a + message. It does pre-empt one the scheduler claimed but has not yet enqueued. - `schedule` now returns a reminder handle instead of `nil`. An operation that ends with `schedule` and relies on an implicit `nil` result should return `nil` explicitly, as `emit` required in 0.15.0. diff --git a/docs/roadmap.md b/docs/roadmap.md index 25838ea..9bbdaaa 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -40,9 +40,10 @@ `unschedule` and `unschedule_all` cancel by name, key, or handle, and `reminder` and `reminders` read the schedule. A cancel is an intent, so it commits with the state change that decided it. A read applies the intents - staged so far, so it agrees with what the commit will leave behind. An - occurrence already claimed by the scheduler still fires; the cancel removes - later occurrences + staged so far, so it agrees with what the commit will leave behind. A cancel + cannot recall an occurrence the scheduler already turned into a message. It + does pre-empt one the scheduler claimed but has not yet enqueued, and the + scheduler treats that as ordinary work rather than a failure - Durable invalidation-only observable broadcasts by default, explicit `broadcast: :value` scalar Turbo replacement, keyed ERB components, signed component locals, and authorized replace or morph refresh. Default diff --git a/lib/solid_objects/activation.rb b/lib/solid_objects/activation.rb index a82cd53..006aef4 100644 --- a/lib/solid_objects/activation.rb +++ b/lib/solid_objects/activation.rb @@ -158,7 +158,8 @@ def build_actor(instance) end actor_class.new( actor_id: instance.actor_id, - state: State.new(actor_class.definition.state_definition, state_data) + state: State.new(actor_class.definition.state_definition, state_data), + instance_id: instance.id ) end diff --git a/lib/solid_objects/actor.rb b/lib/solid_objects/actor.rb index 24297dc..6336737 100644 --- a/lib/solid_objects/actor.rb +++ b/lib/solid_objects/actor.rb @@ -158,10 +158,11 @@ def default_actor_type attr_reader :actor_id, :state - # @rbs (actor_id: String, state: State) -> void - def initialize(actor_id:, state:) + # @rbs (actor_id: String, state: State, ?instance_id: Integer?) -> void + def initialize(actor_id:, state:, instance_id: nil) @actor_id = actor_id @state = state + @instance_id = instance_id @effect_intents = [] @effect_recovery_intents = [] @commit_action_intents = [] @@ -300,6 +301,8 @@ def reminders(operation) reminder_view.each_value.select { |status| status.operation == wanted } end + attr_reader :instance_id + # @rbs (String) -> nil def unschedule_name(name) reminder_intents << UnscheduleIntent.new(name:) @@ -329,7 +332,6 @@ def reminder_view # @rbs () -> Hash[String, ReminderStatus] def committed_reminders - instance_id = Context.current_instance_id return {} unless instance_id Reminder.where(instance_id:).each_with_object({}) do |row, view| diff --git a/lib/solid_objects/actor_snapshot.rb b/lib/solid_objects/actor_snapshot.rb index 43fa307..6873ea4 100644 --- a/lib/solid_objects/actor_snapshot.rb +++ b/lib/solid_objects/actor_snapshot.rb @@ -60,7 +60,8 @@ def build_actor end actor_class.new( actor_id: reference.actor_id, - state: State.new(actor_class.definition.state_definition, state_data) + state: State.new(actor_class.definition.state_definition, state_data), + instance_id: @instance&.id ) end end diff --git a/lib/solid_objects/executor.rb b/lib/solid_objects/executor.rb index 8fe4ba7..f47e435 100644 --- a/lib/solid_objects/executor.rb +++ b/lib/solid_objects/executor.rb @@ -61,7 +61,7 @@ def actor # @rbs (MessageContext) -> untyped def invoke_actor(message_context) - Context.with(actor:, message: message_context, instance_id: activation.lease.instance_id) do + Context.with(actor:, message: message_context) do actor.invoke(message.operation, message.arguments) end end diff --git a/sig/generated/lib/solid_objects/actor.rbs b/sig/generated/lib/solid_objects/actor.rbs index 5a09c5e..051882c 100644 --- a/sig/generated/lib/solid_objects/actor.rbs +++ b/sig/generated/lib/solid_objects/actor.rbs @@ -223,8 +223,8 @@ module SolidObjects attr_reader state: untyped - # @rbs (actor_id: String, state: State) -> void - def initialize: (actor_id: String, state: State) -> void + # @rbs (actor_id: String, state: State, ?instance_id: Integer?) -> void + def initialize: (actor_id: String, state: State, ?instance_id: Integer?) -> void # @rbs () -> MessageContext? def current_message: () -> MessageContext? @@ -264,6 +264,8 @@ module SolidObjects # @rbs (Symbol | String) -> Array[ReminderStatus] def reminders: (Symbol | String) -> Array[ReminderStatus] + attr_reader instance_id: untyped + # @rbs (String) -> nil def unschedule_name: (String) -> nil diff --git a/test/integration/reminder_cancellation_test.rb b/test/integration/reminder_cancellation_test.rb index 5ba99b0..2b17095 100644 --- a/test/integration/reminder_cancellation_test.rb +++ b/test/integration/reminder_cancellation_test.rb @@ -83,6 +83,27 @@ def audit end end + class HookActor < SolidObjects::Actor + actor_type "cancel-hooks" + + attribute :seen_on_activate, default: nil + + observable :armed do + reminder(:ping)&.name + end + + on_activate do + self.seen_on_activate = reminder(:ping)&.name + end + + def arm + schedule(at: Time.utc(2030, 1, 1)).ping + end + + def ping + end + end + class InspectorActor < SolidObjects::Actor actor_type "cancel-inspector" @@ -285,6 +306,45 @@ def state_of(actor_type) assert_equal %w[audit], reminders_for("cancel-chase").pluck(:name) end + test "an activation hook reads the schedule rather than reporting none" do + reference = HookActor.ref("one") + reference.async.arm + drain + SolidObjects::Instance.update_all(activation_owner_id: nil, activation_token: nil, activation_expires_at: nil) + reference.async.ping + drain + + assert_equal "ping", state_of("cancel-hooks").fetch("seen_on_activate") + end + + test "an observable reads the schedule rather than reporting none" do + reference = HookActor.ref("one") + reference.async.arm + drain + + snapshot = SolidObjects::ActorSnapshot.new(reference) + + assert_equal "ping", snapshot.observable_values.fetch("armed") + end + + test "a cancel that lands on a claimed occurrence does not fail the scheduler" do + reference = TrialActor.ref("alice") + reference.async.start_recurring + drain + scheduler = SolidObjects::ReminderScheduler.new + claimed = scheduler.send(:claim_next, now: Time.current) + + assert claimed, "the recurring reminder should be claimable" + + reference.async.convert + drain + + assert_nil scheduler.send(:enqueue, claimed, now: Time.current) + assert_equal 0, state_of("cancel-trial").fetch("expirations") + ensure + scheduler&.stop + end + test "inspection reports the next run time and interval" do reference = InspectorActor.ref("one") reference.async.arm From d12b2e29c1c91463fe1597e119005dcfe14458d9 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Mon, 21 Sep 2026 21:33:54 -0700 Subject: [PATCH 3/6] fix: keep the instance when a turn restores an actor 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. --- lib/solid_objects/activation.rb | 18 ++++++++++----- .../lib/solid_objects/activation.rbs | 7 ++++++ .../integration/reminder_cancellation_test.rb | 22 +++++++++++++++++++ 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/lib/solid_objects/activation.rb b/lib/solid_objects/activation.rb index 006aef4..d74547a 100644 --- a/lib/solid_objects/activation.rb +++ b/lib/solid_objects/activation.rb @@ -3,6 +3,8 @@ module SolidObjects class Activation # @rbs @lease: Lease + # @rbs @actor_id: String + # @rbs @instance_id: Integer # @rbs @actor_class: Class # @rbs @actor: Actor # @rbs @last_used_at: Float @@ -17,6 +19,8 @@ def initialize(lease:) Instance.find(lease.instance_id) end @actor_class = SolidObjects.registry.fetch(instance.actor_type) + @actor_id = instance.actor_id + @instance_id = instance.id @actor = build_actor(instance) @last_used_at = monotonic_now @pass_exhausted = false @@ -86,10 +90,7 @@ def yield_ready_messages # @rbs (Hash[String, untyped]) -> void def restore_state(state_data) - @actor = actor_class.new( - actor_id: actor.actor_id, - state: State.new(actor_class.definition.state_definition, state_data) - ) + @actor = new_actor(state_data) end # @rbs () -> void @@ -156,10 +157,15 @@ def build_actor(instance) ) do actor_class.definition.migrate_state(instance.state_version, instance.state) end + new_actor(state_data) + end + + # @rbs (Hash[String, untyped]) -> Actor + def new_actor(state_data) actor_class.new( - actor_id: instance.actor_id, + actor_id: @actor_id, state: State.new(actor_class.definition.state_definition, state_data), - instance_id: instance.id + instance_id: @instance_id ) end diff --git a/sig/generated/lib/solid_objects/activation.rbs b/sig/generated/lib/solid_objects/activation.rbs index 02c817b..40f6abb 100644 --- a/sig/generated/lib/solid_objects/activation.rbs +++ b/sig/generated/lib/solid_objects/activation.rbs @@ -10,6 +10,10 @@ module SolidObjects @actor_class: Class + @instance_id: Integer + + @actor_id: String + @lease: Lease attr_reader lease: untyped @@ -59,6 +63,9 @@ module SolidObjects # @rbs (Instance) -> Actor def build_actor: (Instance) -> Actor + # @rbs (Hash[String, untyped]) -> Actor + def new_actor: (Hash[String, untyped]) -> Actor + # @rbs () -> void def release_lease: () -> void diff --git a/test/integration/reminder_cancellation_test.rb b/test/integration/reminder_cancellation_test.rb index 2b17095..cd09f7e 100644 --- a/test/integration/reminder_cancellation_test.rb +++ b/test/integration/reminder_cancellation_test.rb @@ -100,6 +100,14 @@ def arm schedule(at: Time.utc(2030, 1, 1)).ping end + def fail_turn + raise "turn failed" + end + + def record_seen + self.seen_on_activate = reminder(:ping)&.name + end + def ping end end @@ -327,6 +335,20 @@ def state_of(actor_type) assert_equal "ping", snapshot.observable_values.fetch("armed") end + test "an actor restored after a failed turn still reads its schedule" do + SolidObjects.configuration.max_attempts = 1 + reference = HookActor.ref("one") + reference.async.arm + drain + # Both messages run in one worker pass, so the activation that the failure + # rebuilt is the one that serves the read. + reference.async.fail_turn + reference.async.record_seen + drain + + assert_equal "ping", state_of("cancel-hooks").fetch("seen_on_activate") + end + test "a cancel that lands on a claimed occurrence does not fail the scheduler" do reference = TrialActor.ref("alice") reference.async.start_recurring From 47c44e27db67e0b2107a146c3baeb30ba5cf5746 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Mon, 21 Sep 2026 22:11:39 -0700 Subject: [PATCH 4/6] fix: refuse an unknown operation in unschedule 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. --- CHANGELOG.md | 3 +++ lib/solid_objects/actor.rb | 11 ++++++++- sig/generated/lib/solid_objects/actor.rbs | 3 +++ .../integration/reminder_cancellation_test.rb | 23 +++++++++++++++++++ 4 files changed, 39 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ced6903..4c6253c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- Refuse an unknown operation in `unschedule` and `unschedule_all`. `schedule` + already raised `UnknownMessage` for one, so a typo cancelled nothing quietly + and left a recurring reminder running. - Add reminder cancellation. `unschedule` removes one reminder by operation and optional key, or by the handle `schedule` now returns. `unschedule_all` removes every key of one operation. Both stage an intent, so a cancel commits diff --git a/lib/solid_objects/actor.rb b/lib/solid_objects/actor.rb index 6336737..a79c4d1 100644 --- a/lib/solid_objects/actor.rb +++ b/lib/solid_objects/actor.rb @@ -279,12 +279,13 @@ def schedule(at:, every: nil, missed: :latest, key: nil) def unschedule(operation_or_handle, key: nil) return unschedule_name(handle_name(operation_or_handle, key:)) if operation_or_handle.is_a?(Hash) + validated_reminder_operation(operation_or_handle) unschedule_name(reminder_name(operation: operation_or_handle, key: validated_reminder_key(key))) end # @rbs (Symbol | String) -> nil def unschedule_all(operation) - reminder_intents << UnscheduleAllIntent.new(operation: operation.to_s) + reminder_intents << UnscheduleAllIntent.new(operation: validated_reminder_operation(operation)) nil end @@ -303,6 +304,14 @@ def reminders(operation) attr_reader :instance_id + # @rbs (Symbol | String) -> String + def validated_reminder_operation(operation) + name = operation.to_s + return name if self.class.definition.messages.key?(name.to_sym) + + raise UnknownMessage, "unknown message #{name.inspect} for #{self.class.actor_type}" + end + # @rbs (String) -> nil def unschedule_name(name) reminder_intents << UnscheduleIntent.new(name:) diff --git a/sig/generated/lib/solid_objects/actor.rbs b/sig/generated/lib/solid_objects/actor.rbs index 051882c..5447898 100644 --- a/sig/generated/lib/solid_objects/actor.rbs +++ b/sig/generated/lib/solid_objects/actor.rbs @@ -266,6 +266,9 @@ module SolidObjects attr_reader instance_id: untyped + # @rbs (Symbol | String) -> String + def validated_reminder_operation: (Symbol | String) -> String + # @rbs (String) -> nil def unschedule_name: (String) -> nil diff --git a/test/integration/reminder_cancellation_test.rb b/test/integration/reminder_cancellation_test.rb index cd09f7e..1c6fb47 100644 --- a/test/integration/reminder_cancellation_test.rb +++ b/test/integration/reminder_cancellation_test.rb @@ -38,6 +38,14 @@ def convert_then_raise raise "turn failed" end + def cancel_unknown + unschedule(:no_such_operation) + end + + def cancel_all_unknown + unschedule_all(:no_such_operation) + end + def convert_with_bad_handle unschedule({ "not_a_reminder" => "x" }) end @@ -275,6 +283,21 @@ def state_of(actor_type) assert_empty reminders_for("cancel-trial") end + test "an unknown operation is refused rather than cancelling nothing" do + SolidObjects.configuration.max_attempts = 1 + reference = TrialActor.ref("alice") + reference.async.start_trial + drain + reference.async.cancel_unknown + reference.async.cancel_all_unknown + drain + + assert_equal 2, SolidObjects::DeadLetter.where(actor_type: "cancel-trial").count + assert_equal [ "SolidObjects::UnknownMessage" ], + SolidObjects::DeadLetter.where(actor_type: "cancel-trial").distinct.pluck(:exception_class) + assert_equal 1, reminders_for("cancel-trial").count + end + test "a malformed handle is rejected" do SolidObjects.configuration.max_attempts = 1 TrialActor.ref("alice").async.convert_with_bad_handle From 0b9af483358201a23393af3e4b81fe533b3ed1f0 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Mon, 21 Sep 2026 23:02:19 -0700 Subject: [PATCH 5/6] fix: refuse an unknown operation when reading reminders 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. --- CHANGELOG.md | 3 ++- lib/solid_objects/actor.rb | 3 ++- .../integration/reminder_cancellation_test.rb | 22 +++++++++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c6253c..a87f9b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,8 @@ ## Unreleased -- Refuse an unknown operation in `unschedule` and `unschedule_all`. `schedule` +- Refuse an unknown operation in `reminder`, `reminders`, `unschedule`, and + `unschedule_all`. `schedule` already raised `UnknownMessage` for one, so a typo cancelled nothing quietly and left a recurring reminder running. - Add reminder cancellation. `unschedule` removes one reminder by operation and diff --git a/lib/solid_objects/actor.rb b/lib/solid_objects/actor.rb index a79c4d1..5838d9c 100644 --- a/lib/solid_objects/actor.rb +++ b/lib/solid_objects/actor.rb @@ -293,12 +293,13 @@ def unschedule_all(operation) def reminder(operation_or_handle, key: nil) return reminder_view[handle_name(operation_or_handle, key:)] if operation_or_handle.is_a?(Hash) + validated_reminder_operation(operation_or_handle) reminder_view[reminder_name(operation: operation_or_handle, key: validated_reminder_key(key))] end # @rbs (Symbol | String) -> Array[ReminderStatus] def reminders(operation) - wanted = operation.to_s + wanted = validated_reminder_operation(operation) reminder_view.each_value.select { |status| status.operation == wanted } end diff --git a/test/integration/reminder_cancellation_test.rb b/test/integration/reminder_cancellation_test.rb index 1c6fb47..cb3daf3 100644 --- a/test/integration/reminder_cancellation_test.rb +++ b/test/integration/reminder_cancellation_test.rb @@ -38,6 +38,14 @@ def convert_then_raise raise "turn failed" end + def read_unknown + reminder(:no_such_operation) + end + + def list_unknown + reminders(:no_such_operation) + end + def cancel_unknown unschedule(:no_such_operation) end @@ -283,6 +291,20 @@ def state_of(actor_type) assert_empty reminders_for("cancel-trial") end + test "an unknown operation is refused when reading rather than reported absent" do + SolidObjects.configuration.max_attempts = 1 + reference = TrialActor.ref("alice") + reference.async.start_trial + drain + reference.async.read_unknown + reference.async.list_unknown + drain + + assert_equal 2, SolidObjects::DeadLetter.where(actor_type: "cancel-trial").count + assert_equal [ "SolidObjects::UnknownMessage" ], + SolidObjects::DeadLetter.where(actor_type: "cancel-trial").distinct.pluck(:exception_class) + end + test "an unknown operation is refused rather than cancelling nothing" do SolidObjects.configuration.max_attempts = 1 reference = TrialActor.ref("alice") From 77c0417a3f2ba5133d17b5af75f9342c52c6ea7a Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Mon, 21 Sep 2026 23:17:34 -0700 Subject: [PATCH 6/6] fix: do not report a spent reminder as armed 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. --- CHANGELOG.md | 4 +++ lib/solid_objects/actor.rb | 2 +- .../integration/reminder_cancellation_test.rb | 28 +++++++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a87f9b2..04fecb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- Leave a one-shot reminder that already fired out of `reminder` and + `reminders`. Its row stays as `completed`, so 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. - Refuse an unknown operation in `reminder`, `reminders`, `unschedule`, and `unschedule_all`. `schedule` already raised `UnknownMessage` for one, so a typo cancelled nothing quietly diff --git a/lib/solid_objects/actor.rb b/lib/solid_objects/actor.rb index 5838d9c..455d28b 100644 --- a/lib/solid_objects/actor.rb +++ b/lib/solid_objects/actor.rb @@ -344,7 +344,7 @@ def reminder_view def committed_reminders return {} unless instance_id - Reminder.where(instance_id:).each_with_object({}) do |row, view| + Reminder.where(instance_id:).where.not(status: "completed").each_with_object({}) do |row, view| view[row.name] = reminder_status( name: row.name, operation: row.operation, diff --git a/test/integration/reminder_cancellation_test.rb b/test/integration/reminder_cancellation_test.rb index cb3daf3..bb06c62 100644 --- a/test/integration/reminder_cancellation_test.rb +++ b/test/integration/reminder_cancellation_test.rb @@ -137,6 +137,14 @@ def arm schedule(at: Time.utc(2030, 1, 1), every: 90).ping end + def arm_due + schedule(at: 1.second.ago).ping + end + + def read_name + self.seen = { "name" => reminder(:ping)&.name } + end + def read_next_run found = reminder(:ping) self.seen = found && { @@ -412,6 +420,26 @@ def state_of(actor_type) scheduler&.stop end + test "inspection does not report a one-shot that already fired" do + reference = InspectorActor.ref("one") + reference.async.arm_due + drain + reference.async.read_name + drain + + assert_equal "ping", state_of("cancel-inspector").fetch("seen").fetch("name") + + scheduler = SolidObjects::ReminderScheduler.new + scheduler.run_once + drain + reference.async.read_name + drain + + assert_nil state_of("cancel-inspector").fetch("seen").fetch("name") + ensure + scheduler&.stop + end + test "inspection reports the next run time and interval" do reference = InspectorActor.ref("one") reference.async.arm