Files
Oxicloud/src/infrastructure/scheduler/engine.rs
T
2026-09-11 22:06:09 +02:00

526 lines
20 KiB
Rust

//! The scheduler supervisor loop.
//!
//! One `tokio::spawn` at startup runs [`SchedulerEngine::run`]. The
//! loop iterates:
//!
//! 1. `pick_next()` — find the job with the earliest `next_run_at`.
//! 2. Sleep until that instant.
//! 3. Dispatch: try-acquire the job's in-flight permit; if held, warn
//! and reschedule; otherwise spawn the handler, apply the timeout,
//! catch panics, record the outcome.
//!
//! Sequential dispatch is intentional. Two jobs due at the same
//! instant run one-after-the-other — the second's `pick_next` fires
//! immediately after the first's dispatch returns, with a zero-length
//! sleep. See `docs/plan/job-registry.md` Part 1 §Runtime model.
use std::sync::Arc;
use std::time::{Duration, Instant};
use chrono::Utc;
use tokio::task::JoinHandle;
use super::registry::{JobEntry, JobRegistry};
use super::types::{ErrCause, JobOutcome, JobRunArgs};
/// Public handle to the running supervisor.
///
/// Dropping the handle does NOT cancel the loop (the spawned task
/// runs until the runtime dies). Explicit shutdown is deferred to
/// whenever graceful-shutdown lands globally — matches the shape
/// every other daemon in the codebase has today. See
/// `docs/plan/job-registry.md` Part 1 §Shutdown coordination.
pub struct SchedulerEngine {
_handle: JoinHandle<()>,
}
impl SchedulerEngine {
/// Spawn the supervisor loop and return a handle. Callers hold
/// the returned `SchedulerEngine` on `AppState` so the task lives
/// as long as the runtime.
pub fn start(registry: Arc<JobRegistry>) -> Self {
let handle = tokio::spawn(async move {
run(registry).await;
});
Self { _handle: handle }
}
}
/// If the registry is empty (no jobs registered yet), sleep this long
/// before rechecking. Registration happens once at boot in the current
/// design, so this only matters as a defensive fallback — in practice
/// the loop enters this branch at most once, right before the first
/// `register()` call completes.
const IDLE_POLL: Duration = Duration::from_secs(60);
async fn run(registry: Arc<JobRegistry>) {
tracing::info!(
target: "oxicloud::scheduler",
event = "scheduler.started",
"periodic scheduler supervisor started"
);
loop {
// `pick_next` only returns scheduled jobs (interval = Some);
// on-demand jobs never appear here and are only reachable
// through `JobRegistry::trigger`.
let Some((name, next_at)) = registry.pick_next().await else {
tokio::time::sleep(IDLE_POLL).await;
continue;
};
// Convert to `Duration`. If `next_at` is in the past (missed
// tick, e.g. very short interval and the previous dispatch
// took longer than the interval), sleep zero and dispatch
// immediately.
let now = Utc::now();
let sleep_dur = (next_at - now)
.to_std()
.unwrap_or_else(|_| Duration::from_millis(0));
if !sleep_dur.is_zero() {
tokio::time::sleep(sleep_dur).await;
}
// The job's `next_run_at` might have changed since `pick_next`
// returned if a concurrent trigger fired — that's fine; the
// dispatch below re-reads via the `JobEntry` snapshot.
let Some(entry) = registry.get(&name).await else {
// Job was unregistered between pick_next and dispatch —
// unreachable in the current design (no unregister), but
// guard defensively.
continue;
};
// Fire and forget from the supervisor's perspective — we
// don't care about the outcome, `dispatch` records it on the
// entry and emits the log line itself. Periodic ticks never
// force — that's an admin-trigger-only affordance. Pass the
// bus reference so periodic runs also publish job events
// (same reasoning as the manual-trigger path).
let bus = registry.message_bus_snapshot();
let _ = dispatch(&name, entry, &JobRunArgs::default(), bus).await;
}
}
/// Dispatch a single run of `name`. Handles:
/// - exclusivity: try-acquire the in-flight permit; skip + warn if held,
/// - spawning under panic containment (via `tokio::spawn` + `JoinHandle`),
/// - timeout enforcement (if `ScheduledJob.timeout` is set),
/// - recording `last_outcome` + advancing `next_run_at` on completion,
/// - emitting the uniform `oxicloud::scheduler::job.run` log line.
///
/// Returns the [`JobOutcome`] the run produced. The scheduler loop
/// discards this (records-only-via-side-effect); admin/programmatic
/// callers via [`JobRegistry::trigger`](super::registry::JobRegistry::trigger)
/// surface it to the caller.
///
/// Non-panicking; every failure path resolves to a `JobOutcome::Err`
/// with a `cause` log field.
///
/// `args` is passed through to `JobHandler::run`. The supervisor's
/// periodic ticks pass `JobRunArgs::default()`; the admin trigger
/// endpoint forwards parsed query params such as `?force=true`.
pub(super) async fn dispatch(
name: &str,
entry: Arc<JobEntry>,
args: &JobRunArgs,
bus: Option<std::sync::Arc<dyn crate::application::ports::message_bus_ports::MessageBus>>,
) -> JobOutcome {
// Try to acquire the single-permit gate. `try_acquire` is
// non-blocking — if held, we know the previous run is still
// executing and skip this tick.
let permit = match entry.in_flight.try_acquire() {
Ok(p) => p,
Err(_) => {
// Someone else holds the permit → previous run still in
// flight. Emit the operator-signal warning and reschedule.
let running_for_ms = {
let state = entry.state.lock().expect("JobState mutex poisoned");
state
.current_run_start
.map(|t| t.elapsed().as_millis())
.unwrap_or(0)
};
// On-demand jobs have `interval = None`; log 0 rather than
// fabricate one. Operators reading this line for a scheduled
// job compare `interval_ms` vs `running_for_ms`; the same
// line for an on-demand job just tells them a concurrent
// trigger raced an in-flight run.
let interval_ms = entry.interval.map(|d| d.as_millis()).unwrap_or(0);
tracing::warn!(
target: "oxicloud::scheduler",
event = "job.tick_skipped",
job = %name,
interval_ms = interval_ms,
running_for_ms = running_for_ms,
"{} still running past its interval — tick skipped",
name,
);
advance_next_run(&entry);
return JobOutcome::ok_with(0, serde_json::json!({ "skipped": "already_running" }));
}
};
// We hold the permit. Record run-start, spawn, await, translate.
{
let mut state = entry.state.lock().expect("JobState mutex poisoned");
state.current_run_start = Some(Instant::now());
}
let started_wall = Utc::now();
let start_instant = Instant::now();
// Publish `JobRunStarted` on `Topic::Job(name)` so the admin
// job dashboard's live tab receives a "started" tick without
// polling. Silent no-op when the bus isn't wired (test setup)
// or when nobody is subscribed. `actor` is `Uuid::nil()` today
// because the scheduler doesn't carry the trigger caller
// through — the periodic supervisor has no caller, and the
// admin trigger endpoints don't thread it in. When they do,
// swap to the real UUID.
if let Some(bus) = bus.as_ref() {
use crate::application::ports::message_bus_ports::{MessageBusEvent, Topic};
bus.publish(
&Topic::Job(name.to_string()),
MessageBusEvent::JobRunStarted {
name: name.to_string(),
started_at: started_wall,
actor: uuid::Uuid::nil(),
},
);
}
// Spawn so panics land as `JoinError::is_panic()` instead of
// unwinding into the supervisor loop. Args cloned into the spawn
// scope so the borrow doesn't outlive the caller.
let handler = entry.handler.clone();
// Normalise HERE, the one funnel every dispatch passes through, so a
// handler always sees its declared parameters with their declared
// defaults — whatever the caller built. The periodic tick in
// particular passes an empty `JobRunArgs::default()`, which would
// otherwise read a `default: true` parameter as false on every
// scheduled run. See `JobRunArgs::normalized_for`.
let args_owned = args.normalized_for(handler.parameters());
let join = tokio::spawn(async move { handler.run(&args_owned).await });
let (outcome, cause) = match entry.timeout {
Some(dur) => match tokio::time::timeout(dur, join).await {
Ok(res) => translate_join(res),
Err(_elapsed) => {
// Timeout fired. The JoinHandle is dropped, which
// aborts the spawned task cooperatively — but abort
// is best-effort in Rust; a handler that ignores
// yield points may run to completion in the background.
// We still record timeout and release the permit.
(
JobOutcome::err(format!("wall-clock timeout of {:?} exceeded", dur)),
Some(ErrCause::Timeout),
)
}
},
None => translate_join(join.await),
};
let elapsed_ms = start_instant.elapsed().as_millis();
// Record outcome and advance the schedule. Permit drops naturally
// when `permit` goes out of scope at the end of the function.
{
let mut state = entry.state.lock().expect("JobState mutex poisoned");
state.current_run_start = None;
state.last_outcome = Some((started_wall, outcome.clone()));
// Only scheduled jobs advance next_run_at. On-demand jobs stay
// at None so `pick_next` never returns them, even after a
// trigger. Same rule as the skip branch — schedule advances
// by one interval, no backlog queueing.
state.next_run_at = entry.interval.map(|dur| {
Utc::now()
+ chrono::Duration::from_std(dur).unwrap_or_else(|_| chrono::Duration::seconds(0))
});
}
// Log line. `outcome=ok` runs are informational; `outcome=err` include
// the diagnostic `cause` field.
log_outcome(name, &outcome, cause, elapsed_ms);
// Publish `JobRunEnded` on `Topic::Job(name)`. This is the
// signal the FE watches for to terminate its subscription
// (`useJobTopic` unsubscribes on `onEnded`). `success = false`
// covers timeout, panic, handler error — the admin dashboard
// renders the row as failed and the "click for details"
// notification (Slice E) will link to `/admin/jobs/<name>`.
// Silent no-op when the bus isn't wired.
if let Some(bus) = bus.as_ref() {
use crate::application::ports::message_bus_ports::{MessageBusEvent, Topic};
let success = outcome.is_ok();
let reason = match &outcome {
crate::infrastructure::scheduler::types::JobOutcome::Err { message } => {
Some(message.clone())
}
_ => None,
};
bus.publish(
&Topic::Job(name.to_string()),
MessageBusEvent::JobRunEnded {
name: name.to_string(),
success,
reason,
ended_at: Utc::now(),
},
);
}
drop(permit);
outcome
}
/// Advance `next_run_at` by one interval without touching outcome or
/// run-start (skip-path helper). No-op for on-demand jobs — `interval`
/// is `None`, so `next_run_at` stays `None` and `pick_next` continues
/// to skip them.
fn advance_next_run(entry: &JobEntry) {
let mut state = entry.state.lock().expect("JobState mutex poisoned");
state.next_run_at = entry.interval.map(|dur| {
Utc::now()
+ chrono::Duration::from_std(dur).unwrap_or_else(|_| chrono::Duration::seconds(0))
});
}
/// Convert the `Result<JobOutcome, JoinError>` returned by the spawned
/// handler into `(JobOutcome, Option<ErrCause>)`. `cause` is `None`
/// on Ok, `Some(_)` on Err.
fn translate_join(
res: Result<JobOutcome, tokio::task::JoinError>,
) -> (JobOutcome, Option<ErrCause>) {
match res {
Ok(outcome) => {
let cause = if outcome.is_ok() {
None
} else {
Some(ErrCause::Handler)
};
(outcome, cause)
}
Err(join_err) if join_err.is_panic() => {
let payload = join_err.into_panic();
let msg = if let Some(s) = payload.downcast_ref::<&'static str>() {
(*s).to_string()
} else if let Some(s) = payload.downcast_ref::<String>() {
s.clone()
} else {
"unknown panic payload".to_string()
};
(
JobOutcome::err(format!("handler panicked: {msg}")),
Some(ErrCause::Panicked),
)
}
Err(join_err) => (
JobOutcome::err(format!("task cancelled: {join_err}")),
Some(ErrCause::Handler),
),
}
}
/// Emit the uniform `oxicloud::scheduler` log line for a completed run.
/// Distinct Ok/Err branches so the tracing macros pick up the fields at
/// compile time — `tracing` doesn't expand conditional field lists.
fn log_outcome(name: &str, outcome: &JobOutcome, cause: Option<ErrCause>, elapsed_ms: u128) {
// Also render elapsed inline in the human-readable message so
// `tail -f` operators see the duration without waiting on a
// structured log renderer to project the `elapsed_ms` field.
let elapsed = format_elapsed(elapsed_ms);
match outcome {
// A paused run is carried as `Ok` — the handler did its job and
// stopped cleanly at a checkpoint — but logging it as `ok` says
// the opposite of what an operator needs to know: the migration
// is blocked and will not progress until the backend returns.
// Same distinction the admin panel draws between a run's STATE
// and its OUTCOME; this line only ever showed the outcome.
JobOutcome::Ok { count, extra }
if extra.get("paused") == Some(&serde_json::Value::Bool(true)) =>
{
tracing::warn!(
target: "oxicloud::scheduler",
event = "job.run",
job = %name,
outcome = "paused",
retryable = extra.get("retryable") == Some(&serde_json::Value::Bool(true)),
count = *count,
elapsed_ms = elapsed_ms,
extra = %extra,
"job {} PAUSED after {} — count={} (resume when the cause clears)",
name,
elapsed,
count,
);
}
JobOutcome::Ok { count, extra } => {
tracing::info!(
target: "oxicloud::scheduler",
event = "job.run",
job = %name,
outcome = "ok",
count = *count,
elapsed_ms = elapsed_ms,
extra = %extra,
"job {} ran in {} — count={}",
name,
elapsed,
count,
);
}
JobOutcome::Err { message: msg } => {
tracing::warn!(
target: "oxicloud::scheduler",
event = "job.run",
job = %name,
outcome = "err",
cause = %cause.unwrap_or(ErrCause::Handler),
elapsed_ms = elapsed_ms,
error = %msg,
"job {} failed after {} — {}",
name,
elapsed,
msg,
);
}
}
}
/// Human-friendly elapsed rendering — `12ms` / `340ms` / `1.4s` /
/// `12.3s` / `4m30s`. The structured `elapsed_ms` field still carries
/// the raw millisecond number for log aggregators.
fn format_elapsed(ms: u128) -> String {
if ms < 1000 {
format!("{}ms", ms)
} else if ms < 60_000 {
format!("{:.1}s", (ms as f64) / 1000.0)
} else {
let secs = ms / 1000;
let m = secs / 60;
let s = secs % 60;
format!("{}m{}s", m, s)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::infrastructure::scheduler::handler::JobHandler;
use async_trait::async_trait;
use std::sync::atomic::{AtomicU64, Ordering};
struct CountingHandler {
name: String,
calls: Arc<AtomicU64>,
sleep: Duration,
}
#[async_trait]
impl JobHandler for CountingHandler {
fn name(&self) -> &str {
&self.name
}
async fn run(&self, _args: &JobRunArgs) -> JobOutcome {
self.calls.fetch_add(1, Ordering::SeqCst);
if !self.sleep.is_zero() {
tokio::time::sleep(self.sleep).await;
}
JobOutcome::ok(1)
}
}
struct PanickingHandler;
#[async_trait]
impl JobHandler for PanickingHandler {
fn name(&self) -> &str {
"panicker"
}
async fn run(&self, _args: &JobRunArgs) -> JobOutcome {
panic!("intentional test panic");
}
}
#[tokio::test]
async fn panic_containment_via_translate_join() {
// Directly exercise translate_join with a spawned panic — the
// supervisor loop's dispatch path uses this same helper.
let handler = Arc::new(PanickingHandler);
let join = tokio::spawn(async move { handler.run(&JobRunArgs::default()).await });
let (outcome, cause) = translate_join(join.await);
assert!(!outcome.is_ok());
assert_eq!(cause, Some(ErrCause::Panicked));
if let JobOutcome::Err { message: msg } = outcome {
assert!(msg.contains("panicked"), "expected panic marker in: {msg}");
}
}
#[tokio::test]
async fn overrun_skips_second_tick() {
// Handler that sleeps 200 ms; two dispatches fired back-to-back
// should see the second skip with a `tick_skipped` warning.
let calls = Arc::new(AtomicU64::new(0));
let handler = Arc::new(CountingHandler {
name: "overrun".to_string(),
calls: calls.clone(),
sleep: Duration::from_millis(200),
});
let registry = Arc::new(JobRegistry::new());
registry
.register(handler, Some(Duration::from_millis(100)), None)
.await;
let entry = registry.get("overrun").await.unwrap();
// Kick off dispatch 1 in the background — it holds the permit
// for ~200 ms.
let entry_bg = entry.clone();
let bg = tokio::spawn(async move {
dispatch("overrun", entry_bg, &JobRunArgs::default(), None).await
});
// Give dispatch 1 time to grab the permit.
tokio::time::sleep(Duration::from_millis(50)).await;
// Dispatch 2 should observe the permit taken and skip.
dispatch("overrun", entry.clone(), &JobRunArgs::default(), None).await;
// Only dispatch 1's handler should have actually run so far.
assert_eq!(calls.load(Ordering::SeqCst), 1);
bg.await.unwrap();
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn timeout_records_err_and_releases_permit() {
let calls = Arc::new(AtomicU64::new(0));
let handler = Arc::new(CountingHandler {
name: "slow".to_string(),
calls: calls.clone(),
sleep: Duration::from_millis(500),
});
let registry = Arc::new(JobRegistry::new());
registry
.register(
handler,
Some(Duration::from_millis(100)),
Some(Duration::from_millis(50)),
)
.await;
let entry = registry.get("slow").await.unwrap();
dispatch("slow", entry.clone(), &JobRunArgs::default(), None).await;
// The timeout fired; last_outcome must be Err.
let state = entry.state.lock().unwrap();
let (_, outcome) = state.last_outcome.as_ref().expect("outcome recorded");
assert!(!outcome.is_ok(), "expected timeout-Err, got {outcome:?}");
// Permit released — another dispatch could acquire it.
assert_eq!(entry.in_flight.available_permits(), 1);
}
}