use std::sync::Arc;
use std::time::Duration;
use serde_json::Value;
use tokio::time;
use tracing::{debug, error, instrument, warn};
use crate::context::CancelSignal;
use crate::runs::handler::{JobContext, JobError};
use crate::runs::log::LogWriter;
use crate::runs::model::{DispatchTicket, JobStatus, TerminalOutcome};
use crate::runs::registry::HandlerRegistry;
use crate::runs::store::Store;
#[instrument(skip_all, name = "runs.dispatcher")]
pub async fn run_dispatcher(
store: Arc<dyn Store>,
registry: HandlerRegistry,
tick_interval: Duration,
cancel: CancelSignal,
) {
let kinds = registry.kinds();
if kinds.is_empty() {
warn!("dispatcher started with empty registry; exiting");
return;
}
loop {
if cancel.is_cancelled() {
debug!("dispatcher cancel signal observed; exiting");
return;
}
let work = match store.next_dispatchable(&kinds).await {
Ok(Some(job)) => Some(job),
Ok(None) => None,
Err(e) => {
error!(error = %e, "next_dispatchable failed");
None
}
};
match work {
Some(job) => {
let max_conc = registry.max_concurrent(&job.kind);
let handler = match registry.get(&job.kind) {
Some(h) => h,
None => {
error!(kind = %job.kind, "handler missing from registry; will not dispatch");
time::sleep(tick_interval).await;
continue;
}
};
let ticket = match store.try_dispatch(job.id, max_conc).await {
Ok(Some(t)) => t,
Ok(None) => {
time::sleep(Duration::from_millis(5)).await;
continue;
}
Err(e) => {
error!(error = %e, job_id = %job.id, "try_dispatch failed");
time::sleep(tick_interval).await;
continue;
}
};
let store_for_task = store.clone();
let handler_for_task = handler.clone();
let kind: Arc<str> = Arc::from(job.kind.as_str());
let inputs = job.inputs.clone();
let bundle_id = job.bundle_id;
let timeout = handler.timeout();
tokio::spawn(async move {
execute_one(
store_for_task,
handler_for_task,
ticket,
kind,
inputs,
bundle_id,
timeout,
)
.await;
});
}
None => {
let cancel_clone = cancel.clone();
tokio::select! {
_ = time::sleep(tick_interval) => {}
_ = wait_until_cancelled(cancel_clone) => return,
}
}
}
}
}
async fn wait_until_cancelled(cancel: CancelSignal) {
let mut interval = time::interval(Duration::from_millis(50));
loop {
interval.tick().await;
if cancel.is_cancelled() {
return;
}
}
}
#[instrument(skip_all, fields(kind = %kind, job_id = %ticket.job_id, run_id = %ticket.run_id))]
async fn execute_one(
store: Arc<dyn Store>,
handler: Arc<dyn crate::runs::handler::JobHandler>,
ticket: DispatchTicket,
kind: Arc<str>,
inputs: Value,
bundle_id: Option<crate::runs::model::BundleId>,
timeout: Option<Duration>,
) {
let cancel = CancelSignal::new();
let log = LogWriter::new(ticket.job_id, ticket.run_id, kind.clone(), store.clone());
let ctx = JobContext {
job_id: ticket.job_id,
run_id: ticket.run_id,
bundle_id,
inputs,
cancel: cancel.clone(),
log,
store: store.clone(),
};
let run_fut = handler.run(ctx);
let outcome = match timeout {
Some(d) => match time::timeout(d, run_fut).await {
Ok(r) => to_terminal(r),
Err(_elapsed) => {
cancel.cancel();
TerminalOutcome::Timeout
}
},
None => to_terminal(run_fut.await),
};
let mutated = match store.complete_job_and_propagate(ticket, outcome).await {
Ok(b) => b,
Err(e) => {
error!(error = %e, "complete_job_and_propagate failed");
return;
}
};
if !mutated {
debug!("complete_job_and_propagate was a no-op (cancel raced or stale generation)");
}
}
fn to_terminal(res: Result<crate::runs::handler::JobOutput, JobError>) -> TerminalOutcome {
match res {
Ok(out) => TerminalOutcome::Success {
output: out.value,
findings_count: out.findings_count,
},
Err(JobError::Cancelled) => TerminalOutcome::Cancelled,
Err(JobError::Timeout) => TerminalOutcome::Timeout,
Err(JobError::Failed(msg)) => TerminalOutcome::Failure {
error: msg,
exit_code: None,
},
Err(JobError::Io(e)) => TerminalOutcome::Failure {
error: format!("io: {e}"),
exit_code: None,
},
}
}
const _: fn() = || {
let _ = JobStatus::Completed;
};