diff --git a/CHANGELOG.md b/CHANGELOG.md index d62f12d..d185a85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,28 @@ another process, so its write cannot clear that cache. A poll inside a request, a job, or `rails runner` reported the first answer forever. The synchronous wait already read uncached. +- 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. 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. +- 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 and left a recurring reminder running. +- 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. ## 0.15.2 - 2026-09-21 diff --git a/docs/roadmap.md b/docs/roadmap.md index cb42e67..9bbdaaa 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -36,6 +36,14 @@ 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. 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..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,9 +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, - state: State.new(actor_class.definition.state_definition, state_data) + actor_id: @actor_id, + 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 50f89b2..455d28b 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 @@ -152,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 = [] @@ -255,20 +262,139 @@ 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) + + 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: validated_reminder_operation(operation)) + 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) + + 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 = validated_reminder_operation(operation) + reminder_view.each_value.select { |status| status.operation == wanted } + end + + 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:) + 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 + return {} unless instance_id + + 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, + 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/actor_snapshot.rb b/lib/solid_objects/actor_snapshot.rb index 2b277d0..2df7dc4 100644 --- a/lib/solid_objects/actor_snapshot.rb +++ b/lib/solid_objects/actor_snapshot.rb @@ -62,7 +62,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/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..f47e435 100644 --- a/lib/solid_objects/executor.rb +++ b/lib/solid_objects/executor.rb @@ -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/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/sig/generated/lib/solid_objects/actor.rbs b/sig/generated/lib/solid_objects/actor.rbs index e64d41c..5447898 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 @@ -172,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? @@ -201,6 +252,46 @@ 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] + + attr_reader instance_id: untyped + + # @rbs (Symbol | String) -> String + def validated_reminder_operation: (Symbol | String) -> String + + # @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..bb06c62 --- /dev/null +++ b/test/integration/reminder_cancellation_test.rb @@ -0,0 +1,492 @@ +# 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 read_unknown + reminder(:no_such_operation) + end + + def list_unknown + reminders(:no_such_operation) + 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 + + 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 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 fail_turn + raise "turn failed" + end + + def record_seen + self.seen_on_activate = reminder(:ping)&.name + end + + def ping + 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 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 && { + "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 "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") + 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 + 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 "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 "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 + 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 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 + 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