#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TriggerKind {
Immediate,
Auto,
At,
Every,
Overnight,
}
impl TriggerKind {
pub fn as_str(self) -> &'static str {
match self {
Self::Immediate => "immediate",
Self::Auto => "auto",
Self::At => "at",
Self::Every => "every",
Self::Overnight => "overnight",
}
}
pub fn parse(raw: &str) -> Option<Self> {
match raw {
"immediate" => Some(Self::Immediate),
"auto" => Some(Self::Auto),
"at" => Some(Self::At),
"every" => Some(Self::Every),
"overnight" => Some(Self::Overnight),
_ => None,
}
}
pub fn fires_on_sight(self) -> bool {
matches!(self, Self::Immediate | Self::Auto)
}
pub fn recurs(self) -> bool {
matches!(self, Self::Every)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TriggerState {
Queued,
Completed,
Cancelled,
}
impl TriggerState {
pub fn as_str(self) -> &'static str {
match self {
Self::Queued => "queued",
Self::Completed => "completed",
Self::Cancelled => "cancelled",
}
}
pub fn parse(raw: &str) -> Option<Self> {
match raw {
"queued" => Some(Self::Queued),
"completed" => Some(Self::Completed),
"cancelled" => Some(Self::Cancelled),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Trigger {
pub state: TriggerState,
pub kind: TriggerKind,
pub eligible_at_ms: Option<i64>,
pub interval_ms: Option<i64>,
}
impl Trigger {
pub fn is_due(&self, now_ms: i64) -> bool {
self.state == TriggerState::Queued
&& (self.kind.fires_on_sight()
|| self
.eligible_at_ms
.is_some_and(|eligible_at_ms| eligible_at_ms <= now_ms))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Event {
Fired,
Completed,
Requeued { eligible_at_ms: i64 },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Effect {
Rearm { eligible_at_ms: i64 },
Complete,
Requeue { eligible_at_ms: i64 },
Fault(Fault),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Fault {
InvalidCadence,
}
pub fn step(trigger: &mut Trigger, event: Event, now_ms: i64) -> Vec<Effect> {
match event {
Event::Fired => {
if trigger.state != TriggerState::Queued {
return Vec::new();
}
if !trigger.kind.recurs() {
trigger.state = TriggerState::Completed;
return vec![Effect::Complete];
}
let rearmed = trigger.eligible_at_ms.zip(trigger.interval_ms).and_then(
|(eligible_at_ms, interval_ms)| rearm_every_at(eligible_at_ms, interval_ms, now_ms),
);
match rearmed {
Some(eligible_at_ms) => {
trigger.eligible_at_ms = Some(eligible_at_ms);
vec![Effect::Rearm { eligible_at_ms }]
}
None => vec![Effect::Fault(Fault::InvalidCadence)],
}
}
Event::Completed => {
if trigger.state != TriggerState::Queued {
return Vec::new();
}
trigger.state = TriggerState::Completed;
vec![Effect::Complete]
}
Event::Requeued { eligible_at_ms } => {
if trigger.state == TriggerState::Cancelled {
return Vec::new();
}
trigger.state = TriggerState::Queued;
trigger.eligible_at_ms = Some(eligible_at_ms);
vec![Effect::Requeue { eligible_at_ms }]
}
}
}
pub fn rearm_every_at(eligible_at_ms: i64, interval_ms: i64, now_ms: i64) -> Option<i64> {
if interval_ms <= 0 || eligible_at_ms > now_ms {
return None;
}
let missed = now_ms.checked_sub(eligible_at_ms)?.div_euclid(interval_ms);
let steps = missed.checked_add(1)?;
eligible_at_ms.checked_add(interval_ms.checked_mul(steps)?)
}
#[cfg(test)]
mod tests {
use super::*;
fn queued(kind: TriggerKind, eligible_at_ms: Option<i64>) -> Trigger {
Trigger {
state: TriggerState::Queued,
kind,
eligible_at_ms,
interval_ms: None,
}
}
#[test]
fn kinds_and_states_round_trip_through_their_stored_spelling() {
for kind in [
TriggerKind::Immediate,
TriggerKind::Auto,
TriggerKind::At,
TriggerKind::Every,
TriggerKind::Overnight,
] {
assert_eq!(TriggerKind::parse(kind.as_str()), Some(kind));
}
for state in [
TriggerState::Queued,
TriggerState::Completed,
TriggerState::Cancelled,
] {
assert_eq!(TriggerState::parse(state.as_str()), Some(state));
}
assert_eq!(TriggerKind::parse("eventually"), None);
assert_eq!(TriggerState::parse("pending"), None);
}
#[test]
fn scheduleless_kinds_are_due_on_sight_and_scheduled_kinds_wait() {
assert!(queued(TriggerKind::Immediate, None).is_due(1_000));
assert!(queued(TriggerKind::Auto, None).is_due(1_000));
assert!(queued(TriggerKind::Immediate, Some(9_000)).is_due(1_000));
for kind in [TriggerKind::At, TriggerKind::Every, TriggerKind::Overnight] {
assert!(!queued(kind, Some(1_001)).is_due(1_000), "{kind:?} early");
assert!(queued(kind, Some(1_000)).is_due(1_000), "{kind:?} on time");
assert!(queued(kind, Some(999)).is_due(1_000), "{kind:?} late");
assert!(!queued(kind, None).is_due(1_000), "{kind:?} unscheduled");
}
}
#[test]
fn only_queued_triggers_are_due() {
for state in [TriggerState::Completed, TriggerState::Cancelled] {
let trigger = Trigger {
state,
kind: TriggerKind::Immediate,
eligible_at_ms: None,
interval_ms: None,
};
assert!(!trigger.is_due(1_000), "{state:?}");
}
}
#[test]
fn firing_retires_a_one_shot_trigger() {
for kind in [
TriggerKind::Immediate,
TriggerKind::Auto,
TriggerKind::At,
TriggerKind::Overnight,
] {
let mut trigger = queued(kind, Some(500));
assert_eq!(
step(&mut trigger, Event::Fired, 1_000),
[Effect::Complete],
"{kind:?}"
);
assert_eq!(trigger.state, TriggerState::Completed);
assert!(!trigger.is_due(1_000));
}
}
#[test]
fn firing_rearms_a_recurring_trigger_past_now() {
let mut trigger = Trigger {
state: TriggerState::Queued,
kind: TriggerKind::Every,
eligible_at_ms: Some(1_000),
interval_ms: Some(60_000),
};
assert_eq!(
step(&mut trigger, Event::Fired, 1_000),
[Effect::Rearm {
eligible_at_ms: 61_000
}]
);
assert_eq!(trigger.state, TriggerState::Queued);
assert_eq!(trigger.eligible_at_ms, Some(61_000));
assert!(!trigger.is_due(1_000));
}
#[test]
fn a_recurring_trigger_without_a_cadence_faults_instead_of_writing() {
for interval_ms in [None, Some(0), Some(-1)] {
let mut trigger = Trigger {
state: TriggerState::Queued,
kind: TriggerKind::Every,
eligible_at_ms: Some(1_000),
interval_ms,
};
assert_eq!(
step(&mut trigger, Event::Fired, 1_000),
[Effect::Fault(Fault::InvalidCadence)],
"{interval_ms:?}"
);
assert_eq!(trigger.state, TriggerState::Queued);
assert_eq!(trigger.eligible_at_ms, Some(1_000));
}
}
#[test]
fn completion_ignores_kind_and_terminal_states_absorb_every_event() {
let mut recurring = Trigger {
state: TriggerState::Queued,
kind: TriggerKind::Every,
eligible_at_ms: Some(1_000),
interval_ms: Some(60_000),
};
assert_eq!(
step(&mut recurring, Event::Completed, 2_000),
[Effect::Complete]
);
assert_eq!(recurring.state, TriggerState::Completed);
assert_eq!(step(&mut recurring, Event::Completed, 3_000), []);
assert_eq!(step(&mut recurring, Event::Fired, 3_000), []);
}
#[test]
fn requeueing_revives_a_completed_trigger_at_the_retry_instant() {
let mut trigger = Trigger {
state: TriggerState::Completed,
kind: TriggerKind::Immediate,
eligible_at_ms: None,
interval_ms: None,
};
assert_eq!(
step(
&mut trigger,
Event::Requeued {
eligible_at_ms: 5_000
},
2_000
),
[Effect::Requeue {
eligible_at_ms: 5_000
}]
);
assert_eq!(trigger.state, TriggerState::Queued);
assert!(trigger.is_due(2_000));
let mut cancelled = Trigger {
state: TriggerState::Cancelled,
..trigger
};
assert_eq!(
step(
&mut cancelled,
Event::Requeued {
eligible_at_ms: 5_000
},
2_000
),
[]
);
assert_eq!(cancelled.state, TriggerState::Cancelled);
}
#[test]
fn rearm_collapses_missed_intervals_into_one_step() {
assert_eq!(rearm_every_at(1_000, 60_000, 1_000), Some(61_000));
assert_eq!(rearm_every_at(1_000, 60_000, 60_999), Some(61_000));
assert_eq!(rearm_every_at(1_000, 60_000, 61_000), Some(121_000));
assert_eq!(rearm_every_at(1_000, 60_000, 3_601_000), Some(3_661_000));
}
#[test]
fn rearm_refuses_impossible_arithmetic() {
assert_eq!(rearm_every_at(2_000, 60_000, 1_000), None);
assert_eq!(rearm_every_at(1_000, 0, 2_000), None);
assert_eq!(rearm_every_at(1_000, -60_000, 2_000), None);
assert_eq!(rearm_every_at(0, i64::MAX, i64::MAX), None);
}
}