use std::future::Future;
use std::pin::Pin;
use std::time::Duration;
use chrono::{DateTime, Datelike, TimeZone, Utc};
use tokio_util::sync::CancellationToken;
use crate::dx::graph::{ScheduleBinding, ScheduleCadence};
type BoxFuture = Pin<Box<dyn Future<Output = Result<(), SchedulerError>> + Send>>;
type EnqueueFn = Box<dyn Fn() -> BoxFuture + Send + Sync>;
struct ScheduleEntry {
kind: &'static str,
version: i16,
cadence: ScheduleCadence,
next_fire: DateTime<Utc>,
fire: EnqueueFn,
}
#[derive(Debug)]
pub enum SchedulerError {
Enqueue(arcature_jobs::EnqueueError),
}
impl std::fmt::Display for SchedulerError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Enqueue(e) => write!(f, "scheduler enqueue failed: {e}"),
}
}
}
impl std::error::Error for SchedulerError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Enqueue(e) => Some(e),
}
}
}
pub struct Scheduler {
entries: Vec<ScheduleEntry>,
}
impl Scheduler {
#[must_use]
pub fn new() -> Self {
Self {
entries: Vec::new(),
}
}
#[must_use]
pub fn schedule<F>(mut self, binding: &ScheduleBinding, fire: F) -> Self
where
F: Fn() -> BoxFuture + Send + Sync + 'static,
{
let next_fire = compute_next_fire(&binding.cadence, Utc::now());
self.entries.push(ScheduleEntry {
kind: binding.job,
version: binding.version,
cadence: binding.cadence.clone(),
next_fire,
fire: Box::new(fire),
});
self
}
#[must_use]
pub fn len(&self) -> usize {
self.entries.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub async fn run(mut self, shutdown: CancellationToken) -> Result<(), SchedulerError> {
if self.entries.is_empty() {
shutdown.cancelled().await;
return Ok(());
}
loop {
let earliest = self
.entries
.iter()
.map(|e| e.next_fire)
.min()
.unwrap_or_else(Utc::now);
let now = Utc::now();
let sleep_duration = if earliest > now {
(earliest - now).to_std().unwrap_or(Duration::from_secs(0))
} else {
Duration::from_secs(0)
};
tokio::select! {
_ = shutdown.cancelled() => return Ok(()),
_ = tokio::time::sleep(sleep_duration) => {}
}
let now = Utc::now();
for entry in &mut self.entries {
if entry.next_fire <= now {
match (entry.fire)().await {
Ok(()) => {}
Err(e) => {
eprintln!(
"scheduler enqueue error for {} v{}: {e}",
entry.kind, entry.version
);
}
}
entry.next_fire = compute_next_fire(&entry.cadence, now);
}
}
}
}
}
impl Default for Scheduler {
fn default() -> Self {
Self::new()
}
}
impl std::fmt::Debug for Scheduler {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Scheduler")
.field("entries", &self.entries.len())
.finish_non_exhaustive()
}
}
fn compute_next_fire(cadence: &ScheduleCadence, now: DateTime<Utc>) -> DateTime<Utc> {
match cadence {
ScheduleCadence::Every { seconds } => {
let dur = chrono::Duration::seconds(i64::try_from(*seconds).unwrap_or(i64::MAX));
now + dur
}
ScheduleCadence::Daily { hour, minute } => {
let h = u32::from(*hour);
let m = u32::from(*minute);
let today = Utc
.with_ymd_and_hms(now.year(), now.month(), now.day(), h, m, 0)
.single();
match today {
Some(t) if t > now => t,
_ => {
let tomorrow = now + chrono::Duration::days(1);
Utc.with_ymd_and_hms(tomorrow.year(), tomorrow.month(), tomorrow.day(), h, m, 0)
.single()
.unwrap_or(now)
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn compute_next_fire_every_adds_interval() {
let now = Utc.with_ymd_and_hms(2026, 1, 1, 12, 0, 0).single().unwrap();
let cadence = ScheduleCadence::Every { seconds: 300 };
let next = compute_next_fire(&cadence, now);
assert_eq!(next, now + chrono::Duration::seconds(300));
}
#[test]
fn compute_next_fire_daily_future_today() {
let now = Utc.with_ymd_and_hms(2026, 1, 1, 12, 0, 0).single().unwrap();
let cadence = ScheduleCadence::Daily {
hour: 15,
minute: 0,
};
let next = compute_next_fire(&cadence, now);
let expected = Utc.with_ymd_and_hms(2026, 1, 1, 15, 0, 0).single().unwrap();
assert_eq!(next, expected);
}
#[test]
fn compute_next_fire_daily_past_today_is_tomorrow() {
let now = Utc.with_ymd_and_hms(2026, 1, 1, 15, 0, 0).single().unwrap();
let cadence = ScheduleCadence::Daily {
hour: 12,
minute: 0,
};
let next = compute_next_fire(&cadence, now);
let expected = Utc.with_ymd_and_hms(2026, 1, 2, 12, 0, 0).single().unwrap();
assert_eq!(next, expected);
}
#[test]
fn compute_next_fire_daily_exact_time_is_tomorrow() {
let now = Utc.with_ymd_and_hms(2026, 1, 1, 12, 0, 0).single().unwrap();
let cadence = ScheduleCadence::Daily {
hour: 12,
minute: 0,
};
let next = compute_next_fire(&cadence, now);
let expected = Utc.with_ymd_and_hms(2026, 1, 2, 12, 0, 0).single().unwrap();
assert_eq!(next, expected);
}
#[test]
fn scheduler_new_is_empty() {
let s = Scheduler::new();
assert!(s.is_empty());
assert_eq!(s.len(), 0);
}
#[tokio::test]
async fn scheduler_run_empty_waits_for_shutdown() {
let s = Scheduler::new();
let shutdown = CancellationToken::new();
let handle = tokio::spawn(s.run(shutdown.clone()));
shutdown.cancel();
let result = handle.await.unwrap();
assert!(result.is_ok());
}
#[tokio::test]
async fn scheduler_shutdown_cancels_during_sleep() {
let binding = ScheduleBinding {
job: "test_job",
version: 1,
cadence: ScheduleCadence::Every { seconds: 3600 },
};
let s = Scheduler::new().schedule(&binding, || Box::pin(async { Ok(()) }));
let shutdown = CancellationToken::new();
let handle = tokio::spawn(s.run(shutdown.clone()));
tokio::time::sleep(Duration::from_millis(10)).await;
shutdown.cancel();
let result = handle.await.unwrap();
assert!(result.is_ok());
}
}