use chrono::{DateTime, Utc};
use crate::{CatchUpPolicy, NodeRegistry, ScheduleCadence, SchedulePolicy, Spec};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ScheduleDecision {
pub tick_at: Option<DateTime<Utc>>,
pub next_at: DateTime<Utc>,
pub catch_up_count: u32,
}
pub fn next_cron_at(expression: &str, after: DateTime<Utc>) -> Result<DateTime<Utc>, String> {
expression
.parse::<cron::Schedule>()
.map_err(|error| format!("invalid cron '{expression}': {error}"))?
.after(&after)
.next()
.ok_or_else(|| format!("cron '{expression}' has no future occurrence"))
}
pub fn next_scheduled_at(
policy: &SchedulePolicy,
after: DateTime<Utc>,
last_completed_at: Option<DateTime<Utc>>,
) -> Result<DateTime<Utc>, String> {
policy.validate().map_err(|error| error.to_string())?;
match &policy.cadence {
ScheduleCadence::Cron { expression } => {
let timezone = policy
.timezone
.parse::<chrono_tz::Tz>()
.map_err(|_| format!("unknown IANA timezone '{}'", policy.timezone))?;
expression
.parse::<cron::Schedule>()
.map_err(|error| format!("invalid cron '{expression}': {error}"))?
.after(&after.with_timezone(&timezone))
.next()
.map(|next| next.with_timezone(&Utc))
.ok_or_else(|| format!("cron '{expression}' has no future occurrence"))
}
ScheduleCadence::FixedRate { milliseconds } => after
.checked_add_signed(chrono::Duration::milliseconds(
i64::try_from(*milliseconds).map_err(|_| "fixed rate exceeds i64")?,
))
.ok_or_else(|| "fixed rate overflows time".into()),
ScheduleCadence::FixedDelay { milliseconds } => last_completed_at
.unwrap_or(after)
.checked_add_signed(chrono::Duration::milliseconds(
i64::try_from(*milliseconds).map_err(|_| "fixed delay exceeds i64")?,
))
.ok_or_else(|| "fixed delay overflows time".into()),
}
}
fn first_after_now(
policy: &SchedulePolicy,
scheduled_at: DateTime<Utc>,
now: DateTime<Utc>,
) -> Result<DateTime<Utc>, String> {
match &policy.cadence {
ScheduleCadence::FixedRate { milliseconds } => {
let period = i64::try_from(*milliseconds).map_err(|_| "fixed rate exceeds i64")?;
let overdue = now
.signed_duration_since(scheduled_at)
.num_milliseconds()
.max(0);
let periods = overdue / period + 1;
scheduled_at
.checked_add_signed(chrono::Duration::milliseconds(
period.saturating_mul(periods),
))
.ok_or_else(|| "fixed rate overflows time".into())
}
ScheduleCadence::FixedDelay { .. } => next_scheduled_at(policy, now, Some(now)),
ScheduleCadence::Cron { .. } => next_scheduled_at(policy, now, None),
}
}
pub fn schedule_decision(
policy: &SchedulePolicy,
scheduled_at: DateTime<Utc>,
now: DateTime<Utc>,
catch_up_count: u32,
) -> Result<ScheduleDecision, String> {
let next_due = next_scheduled_at(policy, scheduled_at, Some(now))?;
let has_missed_window =
!matches!(&policy.cadence, ScheduleCadence::FixedDelay { .. }) && next_due <= now;
if !has_missed_window {
return Ok(ScheduleDecision {
tick_at: Some(scheduled_at),
next_at: next_due,
catch_up_count: 0,
});
}
match policy.catch_up {
CatchUpPolicy::Skip => Ok(ScheduleDecision {
tick_at: None,
next_at: first_after_now(policy, scheduled_at, now)?,
catch_up_count: 0,
}),
CatchUpPolicy::CatchUpOnce => Ok(ScheduleDecision {
tick_at: Some(scheduled_at),
next_at: first_after_now(policy, scheduled_at, now)?,
catch_up_count: 0,
}),
CatchUpPolicy::CatchUpAll { limit } => {
let completed = catch_up_count.saturating_add(1);
let keep_catching_up = completed < limit && next_due <= now;
Ok(ScheduleDecision {
tick_at: Some(scheduled_at),
next_at: if keep_catching_up {
next_due
} else {
first_after_now(policy, scheduled_at, now)?
},
catch_up_count: u32::from(keep_catching_up) * completed,
})
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct BranchIngressPlan {
pub branch_id: String,
pub ingress_type: String,
pub ingress_config: serde_json::Value,
}
pub fn ingress_plans(spec: &Spec, registry: &NodeRegistry) -> Vec<BranchIngressPlan> {
spec.branches
.iter()
.filter_map(|branch| {
let ingress = branch
.nodes
.iter()
.find(|node| registry.is_ingress(&node.node_type))?;
Some(BranchIngressPlan {
branch_id: branch.branch_id.clone(),
ingress_type: ingress.node_type.clone(),
ingress_config: ingress.config.clone(),
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
#[test]
fn timezone_and_fixed_delay_are_explicit() {
let after = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
let cron = SchedulePolicy {
cadence: ScheduleCadence::Cron {
expression: "0 30 9 * * * *".into(),
},
timezone: "Asia/Shanghai".into(),
catch_up: CatchUpPolicy::CatchUpOnce,
};
assert_eq!(
next_scheduled_at(&cron, after, None).unwrap(),
Utc.with_ymd_and_hms(2026, 1, 1, 1, 30, 0).unwrap()
);
let delay = SchedulePolicy {
cadence: ScheduleCadence::FixedDelay {
milliseconds: 1_000,
},
timezone: "UTC".into(),
catch_up: CatchUpPolicy::CatchUpOnce,
};
assert_eq!(
next_scheduled_at(&delay, after, Some(after + chrono::Duration::seconds(10))).unwrap(),
after + chrono::Duration::seconds(11)
);
assert!(SchedulePolicy {
timezone: "not/a-zone".into(),
..delay
}
.validate()
.is_err());
}
#[test]
fn catch_up_policies_advance_the_persisted_cursor_without_a_burst() {
let scheduled = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
let now = scheduled + chrono::Duration::seconds(35);
let policy = |catch_up| SchedulePolicy {
cadence: ScheduleCadence::FixedRate {
milliseconds: 10_000,
},
timezone: "UTC".into(),
catch_up,
};
let skipped = schedule_decision(&policy(CatchUpPolicy::Skip), scheduled, now, 0).unwrap();
assert_eq!(skipped.tick_at, None);
assert_eq!(skipped.next_at, scheduled + chrono::Duration::seconds(40));
let once =
schedule_decision(&policy(CatchUpPolicy::CatchUpOnce), scheduled, now, 0).unwrap();
assert_eq!(once.tick_at, Some(scheduled));
assert_eq!(once.next_at, scheduled + chrono::Duration::seconds(40));
let all = policy(CatchUpPolicy::CatchUpAll { limit: 2 });
let first = schedule_decision(&all, scheduled, now, 0).unwrap();
assert_eq!(first.next_at, scheduled + chrono::Duration::seconds(10));
assert_eq!(first.catch_up_count, 1);
let second = schedule_decision(&all, first.next_at, now, first.catch_up_count).unwrap();
assert_eq!(second.next_at, scheduled + chrono::Duration::seconds(40));
assert_eq!(second.catch_up_count, 0);
}
#[test]
fn cron_uses_timezone_rules_across_dst() {
let policy = SchedulePolicy {
cadence: ScheduleCadence::Cron {
expression: "0 30 2 * * * *".into(),
},
timezone: "America/New_York".into(),
catch_up: CatchUpPolicy::CatchUpOnce,
};
let before_spring_forward = Utc.with_ymd_and_hms(2026, 3, 8, 6, 59, 0).unwrap();
assert_eq!(
next_scheduled_at(&policy, before_spring_forward, None).unwrap(),
Utc.with_ymd_and_hms(2026, 3, 9, 6, 30, 0).unwrap(),
"nonexistent local times are skipped by chrono-tz/cron"
);
}
#[test]
fn next_cron_at_reports_invalid_expressions() {
let after = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
assert_eq!(
next_cron_at("0 0 12 * * * *", after).unwrap(),
Utc.with_ymd_and_hms(2026, 1, 1, 12, 0, 0).unwrap()
);
assert!(next_cron_at("not a cron", after)
.unwrap_err()
.contains("invalid cron"));
}
#[test]
fn next_scheduled_at_rejects_unparseable_cron_and_fixed_rate_measures_from_after() {
let after = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
let broken = SchedulePolicy {
cadence: ScheduleCadence::Cron {
expression: "every day".into(),
},
timezone: "UTC".into(),
catch_up: CatchUpPolicy::Skip,
};
assert!(next_scheduled_at(&broken, after, None).is_err());
let rate = SchedulePolicy {
cadence: ScheduleCadence::FixedRate {
milliseconds: 1_500,
},
timezone: "UTC".into(),
catch_up: CatchUpPolicy::Skip,
};
assert_eq!(
next_scheduled_at(&rate, after, Some(after + chrono::Duration::hours(1))).unwrap(),
after + chrono::Duration::milliseconds(1_500)
);
}
}