use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::time::Duration;
use jiff::Timestamp as Instant;
use tracing::Instrument;
use crate::Executor;
use crate::cron::{CronSchedule, firing_id};
use crate::dequeue::random_unit;
use crate::error::Result;
use crate::rt::JoinHandle;
use crate::schedule::Firing;
use crate::sysdb::types::{ScheduleFilter, ScheduleRecord, ScheduleStatus, Timestamp};
use crate::workflow::{Role, spawn_tracked};
const STARTUP_FAST_POLL_DURATION: Duration = Duration::from_secs(60);
const STARTUP_FAST_POLL_INTERVAL: Duration = Duration::from_secs(1);
const MAX_JITTER: Duration = Duration::from_secs(10);
pub(crate) fn spawn(executor: Arc<Executor>, polling_interval: Duration) {
spawn_tracked(
&executor,
Role::Producer,
"scheduler".to_owned(),
poll_schedules(Arc::clone(&executor), polling_interval)
.instrument(tracing::info_span!("scheduler")),
);
}
fn same_definition(a: &ScheduleRecord, b: &ScheduleRecord) -> bool {
a.workflow_name == b.workflow_name
&& a.workflow_class_name == b.workflow_class_name
&& a.schedule == b.schedule
&& a.context == b.context
&& a.cron_timezone == b.cron_timezone
&& a.queue_name == b.queue_name
}
struct Running {
record: Arc<ScheduleRecord>,
task: Task,
}
enum Task {
Live(JoinHandle<Ended>),
Unfireable,
}
impl Task {
fn abort(&self) {
if let Task::Live(handle) = self {
handle.abort();
}
}
}
async fn poll_schedules(executor: Arc<Executor>, polling_interval: Duration) {
let fast_until = std::time::Instant::now() + STARTUP_FAST_POLL_DURATION;
let mut running: HashMap<String, Running> = HashMap::new();
loop {
match executor
.sysdb()
.list_schedules(&ScheduleFilter::default(), None)
.await
{
Ok(schedules) => reconcile_schedules(&executor, &mut running, schedules).await,
Err(error) => tracing::warn!(error = %error, "could not list schedules"),
}
let wait = if std::time::Instant::now() < fast_until {
STARTUP_FAST_POLL_INTERVAL.min(polling_interval)
} else {
polling_interval
};
if !executor.sleep_unless_stopping(wait).await {
return;
}
}
}
async fn reconcile_schedules(
executor: &Arc<Executor>,
running: &mut HashMap<String, Running>,
schedules: Vec<ScheduleRecord>,
) {
let present: HashSet<&str> = schedules.iter().map(|s| s.schedule_id.as_str()).collect();
running.retain(|id, schedule| {
let keep = present.contains(id.as_str());
if !keep {
schedule.task.abort();
}
keep
});
for record in schedules {
if record.status != ScheduleStatus::Active {
if let Some(stopped) = running.remove(&record.schedule_id) {
tracing::debug!(schedule = record.schedule_name, "the schedule is paused");
stopped.task.abort();
}
continue;
}
let catch_up = match running.get_mut(&record.schedule_id) {
Some(current) if same_definition(¤t.record, &record) => {
let Task::Live(handle) = &mut current.task else {
continue;
};
if !handle.is_finished() {
continue;
}
match handle.await {
Ok(Ended::Unfireable) => {
current.task = Task::Unfireable;
continue;
}
Err(error) if error.is_panic() => {
tracing::error!(
"the task firing schedule `{}` panicked; it is stopped until the \
schedule's definition changes",
record.schedule_name
);
current.task = Task::Unfireable;
continue;
}
Ok(Ended::Stopped) => continue,
Ok(Ended::Superseded) | Err(_) => true,
}
}
Some(_) => {
tracing::info!(
schedule = record.schedule_name,
"the schedule's definition changed; restarting it"
);
if let Some(stale) = running.remove(&record.schedule_id) {
stale.task.abort();
}
false
}
None => true,
};
let span = tracing::info_span!("schedule", name = record.schedule_name);
let record = Arc::new(record);
let task = spawn_tracked(
executor,
Role::Producer,
format!("schedule {}", record.schedule_name),
run_schedule(Arc::clone(executor), Arc::clone(&record), catch_up).instrument(span),
);
running.insert(
record.schedule_id.clone(),
Running {
record,
task: Task::Live(task),
},
);
}
}
enum Ended {
Unfireable,
Superseded,
Stopped,
}
const REFRESH_INTERVAL: Duration = Duration::from_secs(1);
const MAX_RETRY_INTERVAL: Duration = Duration::from_secs(60);
async fn run_schedule(
executor: Arc<Executor>,
record: Arc<ScheduleRecord>,
catch_up: bool,
) -> Ended {
let cron = match CronSchedule::parse(&record.schedule, record.cron_timezone.as_deref()) {
Ok(cron) => cron,
Err(detail) => {
tracing::error!("cannot run schedule `{}`: {detail}", record.schedule_name);
return Ended::Unfireable;
}
};
let now = Instant::now();
let mut cursor = match record.last_fired_at {
Some(last) if catch_up && record.automatic_backfill => {
let last = Instant::from_millisecond(last.as_epoch_ms()).unwrap_or(now);
if last < now {
tracing::info!(
schedule = record.schedule_name,
since = %last,
"catching up on ticks missed while the schedule was not running"
);
}
last.min(now)
}
_ => now,
};
let mut fresh: Option<(Firing, std::time::Instant)> = None;
let mut paused = false;
loop {
let Some(tick) = cron.next_after(cursor) else {
tracing::error!(
"schedule `{}` no longer fires; stopping it",
record.schedule_name
);
return Ended::Unfireable;
};
cursor = tick.timestamp();
let until = Duration::try_from(cursor.duration_since(Instant::now())).unwrap_or_default();
if !executor.sleep_unless_stopping(until + jitter(until)).await {
return Ended::Stopped;
}
let workflow_id = firing_id(&record.schedule_name, &tick);
let next_tick = cron.next_after(cursor).map(|next| next.timestamp());
let catching_up = next_tick.is_some_and(|next| next <= Instant::now());
let mut tick_state = TickState {
paused,
enqueued: false,
};
let mut wait = Duration::from_secs(1);
loop {
match attempt_tick(
&executor,
&record,
&mut fresh,
&mut tick_state,
cursor,
&workflow_id,
)
.await
{
Ok(Attempt::Fired) => {
paused = false;
break;
}
Ok(Attempt::Paused) => {
paused = true;
break;
}
Ok(Attempt::Resumed {
last_fired_at,
automatic_backfill,
}) => {
paused = false;
cursor = resume_cursor(automatic_backfill, last_fired_at, cursor);
break;
}
Ok(Attempt::Superseded) => {
tracing::info!(
schedule = record.schedule_name,
"the schedule was changed or deleted; stopping it"
);
return Ended::Superseded;
}
Ok(Attempt::Unfireable(detail)) => {
tracing::error!("cannot run schedule `{}`: {detail}", record.schedule_name);
return Ended::Unfireable;
}
Err(error) => {
if give_up(next_tick, catching_up, Instant::now(), wait) {
tracing::error!(
workflow_id,
error = %error,
"could not fire schedule `{}`; giving up on this tick",
record.schedule_name
);
break;
}
tracing::warn!(
workflow_id,
error = %error,
"could not fire schedule `{}`; retrying",
record.schedule_name
);
if !executor.sleep_unless_stopping(wait).await {
return Ended::Stopped;
}
wait = (wait * 2).min(MAX_RETRY_INTERVAL);
fresh = None;
}
}
}
}
}
fn give_up(next_tick: Option<Instant>, catching_up: bool, now: Instant, wait: Duration) -> bool {
let Some(next_tick) = next_tick else {
return false;
};
if catching_up {
return false;
}
match Duration::try_from(next_tick.duration_since(now)) {
Ok(remaining) => remaining <= wait,
Err(_) => true,
}
}
fn resume_cursor(
automatic_backfill: bool,
last_fired_at: Option<Timestamp>,
tick: Instant,
) -> Instant {
let just_before = tick - jiff::SignedDuration::from_nanos(1);
match last_fired_at {
Some(last) if automatic_backfill => Instant::from_millisecond(last.as_epoch_ms())
.map_or(just_before, |last| last.min(just_before)),
_ => just_before,
}
}
struct TickState {
paused: bool,
enqueued: bool,
}
enum Attempt {
Fired,
Paused,
Resumed {
last_fired_at: Option<Timestamp>,
automatic_backfill: bool,
},
Superseded,
Unfireable(String),
}
async fn attempt_tick(
executor: &Executor,
record: &ScheduleRecord,
fresh: &mut Option<(Firing, std::time::Instant)>,
state: &mut TickState,
tick: Instant,
workflow_id: &str,
) -> Result<Attempt> {
let conn = executor.connection();
let firing = match fresh {
Some((firing, read_at)) if read_at.elapsed() < REFRESH_INTERVAL => firing,
_ => {
let Some(current) = conn
.get_schedule(&record.schedule_name, None)
.await?
.filter(|current| {
current.schedule_id == record.schedule_id && same_definition(current, record)
})
else {
return Ok(Attempt::Superseded);
};
if current.status != ScheduleStatus::Active {
*fresh = None;
return Ok(Attempt::Paused);
}
if state.paused {
return Ok(Attempt::Resumed {
last_fired_at: current.last_fired_at,
automatic_backfill: current.automatic_backfill,
});
}
let prepared = match conn
.prepare_firing(¤t, Some(executor.workflows()))
.await
{
Ok(prepared) => prepared,
Err(crate::Error::Deserialization { message, .. }) => {
return Ok(Attempt::Unfireable(format!(
"its context cannot be decoded: {message}"
)));
}
Err(error) => return Err(error),
};
&fresh.insert((prepared, std::time::Instant::now())).0
}
};
if conn
.fire_unless_fired(record, firing, tick, workflow_id)
.await?
{
state.enqueued = true;
}
if state.enqueued {
conn.sysdb()
.update_schedule_last_fired_at(
&record.schedule_name,
Timestamp::from_epoch_ms(tick.as_millisecond()),
)
.await
.map_err(crate::Error::SystemDatabase)?;
}
Ok(Attempt::Fired)
}
fn jitter(until: Duration) -> Duration {
(until / 10).min(MAX_JITTER).mul_f64(random_unit())
}
#[cfg(test)]
mod tests {
use super::*;
fn at(rfc3339: &str) -> Instant {
rfc3339.parse().unwrap()
}
#[test]
fn a_live_tick_is_retried_until_the_next_tick_would_overtake_it() {
let now = at("2026-10-05T12:00:00Z");
let wait = Duration::from_secs(4);
assert!(!give_up(Some(at("2026-10-05T13:00:00Z")), false, now, wait));
assert!(give_up(Some(at("2026-10-05T12:00:02Z")), false, now, wait));
assert!(give_up(Some(at("2026-10-05T11:59:00Z")), false, now, wait));
}
#[test]
fn a_tick_of_a_backlog_or_the_last_tick_is_never_given_up() {
let now = at("2026-10-05T12:00:00Z");
let wait = Duration::from_secs(4);
assert!(!give_up(Some(at("2026-10-05T11:00:00Z")), true, now, wait));
assert!(!give_up(Some(at("2026-10-05T12:00:02Z")), true, now, wait));
assert!(!give_up(None, false, now, wait));
}
#[test]
fn a_resume_with_automatic_backfill_walks_from_the_last_firing() {
let tick = at("2026-10-05T12:00:20Z");
let last = Timestamp::from_epoch_ms(at("2026-10-05T12:00:05Z").as_millisecond());
assert_eq!(
resume_cursor(true, Some(last), tick),
at("2026-10-05T12:00:05Z")
);
let later = Timestamp::from_epoch_ms(at("2026-10-05T12:01:00Z").as_millisecond());
assert!(resume_cursor(true, Some(later), tick) < tick);
assert!(resume_cursor(true, None, tick) < tick);
}
#[test]
fn a_resume_without_automatic_backfill_runs_only_the_current_tick() {
let tick = at("2026-10-05T12:00:20Z");
let last = Timestamp::from_epoch_ms(at("2026-10-05T12:00:05Z").as_millisecond());
let cursor = resume_cursor(false, Some(last), tick);
assert!(cursor < tick);
assert!(cursor > at("2026-10-05T12:00:19Z"));
let cron = CronSchedule::parse("* * * * * *", None).unwrap();
assert_eq!(cron.next_after(cursor).unwrap().timestamp(), tick);
}
#[test]
fn jitter_is_at_most_a_tenth_and_at_most_ten_seconds() {
for _ in 0..1000 {
assert!(jitter(Duration::from_secs(5)) <= Duration::from_millis(500));
assert!(jitter(Duration::from_secs(3600)) <= MAX_JITTER);
}
assert_eq!(jitter(Duration::ZERO), Duration::ZERO);
}
fn row() -> ScheduleRecord {
ScheduleRecord {
schedule_id: "id".into(),
schedule_name: "s".into(),
workflow_name: "w".into(),
workflow_class_name: None,
schedule: "* * * * *".into(),
status: ScheduleStatus::Active,
context: "null".into(),
last_fired_at: None,
automatic_backfill: false,
cron_timezone: None,
queue_name: None,
application_name: None,
}
}
#[test]
fn what_the_loop_writes_is_not_part_of_the_definition() {
let record = row();
let same = ScheduleRecord {
schedule_id: "other-id".into(),
schedule_name: "renamed".into(),
status: ScheduleStatus::Paused,
last_fired_at: Some(Timestamp::from_epoch_ms(1)),
automatic_backfill: true,
application_name: Some("app".into()),
..row()
};
assert!(same_definition(&record, &same));
assert!(
same_definition(&record, &record),
"a row is its own definition"
);
}
#[test]
fn every_field_of_the_definition_restarts_the_task() {
let record = row();
for (field, changed) in [
(
"workflow_name",
ScheduleRecord {
workflow_name: "w2".into(),
..row()
},
),
(
"workflow_class_name",
ScheduleRecord {
workflow_class_name: Some("C".into()),
..row()
},
),
(
"schedule",
ScheduleRecord {
schedule: "0 * * * *".into(),
..row()
},
),
(
"context",
ScheduleRecord {
context: "1".into(),
..row()
},
),
(
"cron_timezone",
ScheduleRecord {
cron_timezone: Some("UTC".into()),
..row()
},
),
(
"queue_name",
ScheduleRecord {
queue_name: Some("q".into()),
..row()
},
),
] {
assert!(!same_definition(&record, &changed), "{field} was ignored");
assert!(
!same_definition(&changed, &record),
"{field} was ignored the other way"
);
}
}
}