use crate::completion::CompletionBatcherHandle;
use crate::context::{CallbackGuard, JobContext};
use crate::events::{BoxedUntypedEventHandler, UntypedJobEvent};
use crate::runtime::{InFlightMap, InFlightState, ProgressState};
use awa_model::{AwaError, JobRow};
use sqlx::PgPool;
use std::any::Any;
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tracing::{error, info, info_span, warn, Instrument};
#[derive(Debug)]
pub enum JobResult {
Completed,
RetryAfter(std::time::Duration),
Snooze(std::time::Duration),
Cancel(String),
WaitForCallback(CallbackGuard),
}
#[derive(Debug, thiserror::Error)]
pub enum JobError {
#[error("{0}")]
Retryable(#[source] Box<dyn std::error::Error + Send + Sync>),
#[error("terminal: {0}")]
Terminal(String),
}
impl JobError {
pub fn retryable(err: impl std::error::Error + Send + Sync + 'static) -> Self {
JobError::Retryable(Box::new(err))
}
pub fn retryable_msg(msg: impl std::fmt::Display) -> Self {
JobError::Retryable(Box::new(DisplayError(msg.to_string())))
}
pub fn terminal(msg: impl Into<String>) -> Self {
JobError::Terminal(msg.into())
}
}
#[derive(Debug)]
struct DisplayError(String);
impl std::fmt::Display for DisplayError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for DisplayError {}
#[cfg(feature = "anyhow")]
impl From<anyhow::Error> for JobError {
fn from(err: anyhow::Error) -> Self {
JobError::retryable_msg(format!("{err:#}"))
}
}
#[async_trait::async_trait]
pub trait Worker: Send + Sync + 'static {
fn kind(&self) -> &'static str;
async fn perform(&self, ctx: &JobContext) -> Result<JobResult, JobError>;
}
pub(crate) type BoxedWorker = Box<dyn Worker>;
#[allow(clippy::large_enum_variant)]
enum CompletionOutcome {
Applied { event: Option<UntypedJobEvent> },
IgnoredStale,
}
pub struct JobExecutor {
pool: PgPool,
workers: Arc<HashMap<String, BoxedWorker>>,
lifecycle_handlers: Arc<HashMap<String, Vec<BoxedUntypedEventHandler>>>,
in_flight: InFlightMap,
queue_in_flight: Arc<HashMap<String, Arc<AtomicU32>>>,
state: Arc<HashMap<std::any::TypeId, Box<dyn Any + Send + Sync>>>,
metrics: crate::metrics::AwaMetrics,
completion_batcher: CompletionBatcherHandle,
}
impl JobExecutor {
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
pool: PgPool,
workers: Arc<HashMap<String, BoxedWorker>>,
lifecycle_handlers: Arc<HashMap<String, Vec<BoxedUntypedEventHandler>>>,
in_flight: InFlightMap,
queue_in_flight: Arc<HashMap<String, Arc<AtomicU32>>>,
state: Arc<HashMap<std::any::TypeId, Box<dyn Any + Send + Sync>>>,
metrics: crate::metrics::AwaMetrics,
completion_batcher: CompletionBatcherHandle,
) -> Self {
Self {
pool,
workers,
lifecycle_handlers,
in_flight,
queue_in_flight,
state,
metrics,
completion_batcher,
}
}
pub fn execute_task(
&self,
job: JobRow,
cancel: Arc<AtomicBool>,
) -> impl std::future::Future<Output = ()> + Send + 'static {
let pool = self.pool.clone();
let workers = self.workers.clone();
let lifecycle_handlers = self.lifecycle_handlers.clone();
let in_flight = self.in_flight.clone();
let queue_in_flight = self.queue_in_flight.clone();
let state = self.state.clone();
let metrics = self.metrics.clone();
let completion_batcher = self.completion_batcher.clone();
let job_id = job.id;
let job_run_lease = job.run_lease;
let job_kind = job.kind.clone();
let job_queue = job.queue.clone();
let span = info_span!(
"job.execute",
job.id = job_id,
job.kind = %job_kind,
job.queue = %job_queue,
job.attempt = job.attempt,
otel.name = %format!("job.execute {}", job_kind),
otel.status_code = tracing::field::Empty,
);
async move {
let progress_state = Arc::new(std::sync::Mutex::new(ProgressState::new(
job.progress.clone(),
)));
let in_flight_state = InFlightState {
cancel: cancel.clone(),
progress: progress_state.clone(),
};
in_flight.insert((job_id, job_run_lease), in_flight_state);
if let Some(counter) = queue_in_flight.get(&job_queue) {
counter.fetch_add(1, Ordering::SeqCst);
}
metrics.record_in_flight_change(&job_queue, 1);
let start = std::time::Instant::now();
let ctx = JobContext::new(
job.clone(),
cancel,
state,
pool.clone(),
progress_state.clone(),
);
let result = match workers.get(&job.kind) {
Some(worker) => worker.perform(&ctx).await,
None => {
error!(kind = %job.kind, job_id, "No worker registered for job kind");
Err(JobError::Terminal(format!(
"unknown job kind: {}",
job.kind
)))
}
};
let duration = start.elapsed();
let progress_snapshot = {
let guard = progress_state.lock().expect("progress lock poisoned");
guard.clone_latest()
};
let has_lifecycle_handlers = lifecycle_handlers.contains_key(&job_kind);
let outcome = complete_job(
&pool,
&job,
&result,
&completion_batcher,
progress_snapshot,
duration,
has_lifecycle_handlers,
)
.await;
match &outcome {
Ok(CompletionOutcome::Applied { .. }) => {
match &result {
Ok(JobResult::Completed) => {
metrics.record_job_completed(&job_kind, &job_queue, duration);
}
Ok(JobResult::RetryAfter(_)) => {
metrics.record_job_retried(&job_kind, &job_queue);
}
Ok(JobResult::Cancel(_)) => {
metrics.jobs_cancelled.add(
1,
&[
opentelemetry::KeyValue::new("awa.job.kind", job_kind.clone()),
opentelemetry::KeyValue::new(
"awa.job.queue",
job_queue.clone(),
),
],
);
}
Ok(JobResult::Snooze(_)) => {} Ok(JobResult::WaitForCallback(_)) => {
metrics.jobs_waiting_external.add(
1,
&[
opentelemetry::KeyValue::new("awa.job.kind", job_kind.clone()),
opentelemetry::KeyValue::new(
"awa.job.queue",
job_queue.clone(),
),
],
);
}
Err(JobError::Terminal(_)) => {
metrics.record_job_failed(&job_kind, &job_queue, true);
}
Err(JobError::Retryable(_)) => {
metrics.record_job_retried(&job_kind, &job_queue);
}
}
}
Ok(CompletionOutcome::IgnoredStale) => {
}
Err(err) => {
error!(job_id, error = %err, "Failed to complete job");
}
}
in_flight.remove((job_id, job_run_lease));
if let Some(counter) = queue_in_flight.get(&job_queue) {
counter.fetch_sub(1, Ordering::SeqCst);
}
metrics.record_in_flight_change(&job_queue, -1);
if let Ok(CompletionOutcome::Applied {
event: Some(event), ..
}) = outcome
{
let handlers = lifecycle_handlers.clone();
let kind = job_kind.clone();
tokio::spawn(async move {
dispatch_lifecycle_event(&handlers, &kind, event).await;
});
}
}
.instrument(span)
}
}
async fn complete_job(
pool: &PgPool,
job: &JobRow,
result: &Result<JobResult, JobError>,
completion_batcher: &CompletionBatcherHandle,
progress_snapshot: Option<serde_json::Value>,
duration: Duration,
needs_event: bool,
) -> Result<CompletionOutcome, AwaError> {
match result {
Ok(JobResult::Completed) => {
tracing::Span::current().record("otel.status_code", "OK");
info!(job_id = job.id, kind = %job.kind, attempt = job.attempt, "Job completed");
let result = match completion_batcher.complete(job.id, job.run_lease).await {
Ok(updated) => updated,
Err(err) => {
warn!(
job_id = job.id,
error = %err,
"Completion batch flush failed, falling back to direct finalize"
);
direct_complete_job(pool, job).await?
}
};
if !result {
warn!(
job_id = job.id,
"Job already rescued/cancelled, completion ignored"
);
return Ok(CompletionOutcome::IgnoredStale);
}
if needs_event {
let updated_job: JobRow = sqlx::query_as("SELECT * FROM awa.jobs WHERE id = $1")
.bind(job.id)
.fetch_one(pool)
.await?;
Ok(CompletionOutcome::Applied {
event: Some(UntypedJobEvent::Completed {
job: updated_job,
duration,
}),
})
} else {
Ok(CompletionOutcome::Applied { event: None })
}
}
Ok(JobResult::RetryAfter(retry_duration)) => {
let seconds = retry_duration.as_secs() as f64;
info!(
job_id = job.id,
kind = %job.kind,
retry_after_secs = seconds,
"Job requested retry after duration"
);
let result = sqlx::query(
r#"
UPDATE awa.jobs
SET state = 'retryable',
run_at = now() + make_interval(secs => $2),
finalized_at = now(),
progress = $4
WHERE id = $1 AND state = 'running' AND run_lease = $3
"#,
)
.bind(job.id)
.bind(seconds)
.bind(job.run_lease)
.bind(&progress_snapshot)
.execute(pool)
.await?;
if result.rows_affected() == 0 {
warn!(
job_id = job.id,
"Job already rescued/cancelled, retry ignored"
);
return Ok(CompletionOutcome::IgnoredStale);
}
if needs_event {
let updated_job: JobRow = sqlx::query_as("SELECT * FROM awa.jobs WHERE id = $1")
.bind(job.id)
.fetch_one(pool)
.await?;
Ok(CompletionOutcome::Applied {
event: Some(UntypedJobEvent::Retried {
job: updated_job.clone(),
error: String::new(),
attempt: updated_job.attempt,
next_run_at: updated_job.run_at,
}),
})
} else {
Ok(CompletionOutcome::Applied { event: None })
}
}
Ok(JobResult::Snooze(snooze_duration)) => {
let seconds = snooze_duration.as_secs() as f64;
info!(
job_id = job.id,
kind = %job.kind,
snooze_secs = seconds,
"Job snoozed (attempt not incremented)"
);
let result = sqlx::query(
r#"
UPDATE awa.jobs
SET state = 'scheduled',
run_at = now() + make_interval(secs => $2),
attempt = attempt - 1,
heartbeat_at = NULL,
deadline_at = NULL,
progress = $4
WHERE id = $1 AND state = 'running' AND run_lease = $3
"#,
)
.bind(job.id)
.bind(seconds)
.bind(job.run_lease)
.bind(&progress_snapshot)
.execute(pool)
.await?;
if result.rows_affected() == 0 {
warn!(
job_id = job.id,
"Job already rescued/cancelled, snooze ignored"
);
return Ok(CompletionOutcome::IgnoredStale);
}
Ok(CompletionOutcome::Applied { event: None })
}
Ok(JobResult::Cancel(reason)) => {
info!(
job_id = job.id,
kind = %job.kind,
reason = %reason,
"Job cancelled by handler"
);
let result = sqlx::query(
r#"
UPDATE awa.jobs
SET state = 'cancelled',
finalized_at = now(),
errors = errors || $2::jsonb,
progress = $4
WHERE id = $1 AND state = 'running' AND run_lease = $3
"#,
)
.bind(job.id)
.bind(serde_json::json!({
"error": format!("cancelled: {}", reason),
"attempt": job.attempt,
"at": chrono::Utc::now().to_rfc3339()
}))
.bind(job.run_lease)
.bind(&progress_snapshot)
.execute(pool)
.await?;
if result.rows_affected() == 0 {
warn!(
job_id = job.id,
"Job already rescued/cancelled, cancel ignored"
);
return Ok(CompletionOutcome::IgnoredStale);
}
if needs_event {
let updated_job: JobRow = sqlx::query_as("SELECT * FROM awa.jobs WHERE id = $1")
.bind(job.id)
.fetch_one(pool)
.await?;
Ok(CompletionOutcome::Applied {
event: Some(UntypedJobEvent::Cancelled {
job: updated_job,
reason: reason.clone(),
}),
})
} else {
Ok(CompletionOutcome::Applied { event: None })
}
}
Ok(JobResult::WaitForCallback(_guard)) => {
info!(
job_id = job.id,
kind = %job.kind,
"Job waiting for external callback"
);
let result = sqlx::query(
r#"
UPDATE awa.jobs
SET state = 'waiting_external',
heartbeat_at = NULL,
deadline_at = NULL,
progress = $3
WHERE id = $1 AND state = 'running' AND run_lease = $2 AND callback_id IS NOT NULL
"#,
)
.bind(job.id)
.bind(job.run_lease)
.bind(&progress_snapshot)
.execute(pool)
.await?;
if result.rows_affected() == 0 {
let current: Option<(awa_model::JobState, Option<uuid::Uuid>)> =
sqlx::query_as("SELECT state, callback_id FROM awa.jobs WHERE id = $1")
.bind(job.id)
.fetch_optional(pool)
.await?;
match current {
Some((state, _)) if state.is_terminal() => {
info!(
job_id = job.id,
state = %state,
"Job already completed by racing callback"
);
return Ok(CompletionOutcome::Applied { event: None });
}
Some((_, None)) => {
error!(
job_id = job.id,
"WaitForCallback returned without calling register_callback"
);
sqlx::query(
r#"
UPDATE awa.jobs
SET state = 'failed',
finalized_at = now(),
errors = errors || $2::jsonb
WHERE id = $1 AND state = 'running' AND run_lease = $3
"#,
)
.bind(job.id)
.bind(serde_json::json!({
"error": "WaitForCallback returned without calling register_callback",
"attempt": job.attempt,
"at": chrono::Utc::now().to_rfc3339(),
"terminal": true
}))
.bind(job.run_lease)
.execute(pool)
.await?;
return Ok(CompletionOutcome::Applied { event: None });
}
_ => {
warn!(
job_id = job.id,
"Job already rescued/cancelled, wait-for-callback ignored"
);
return Ok(CompletionOutcome::IgnoredStale);
}
}
}
Ok(CompletionOutcome::Applied { event: None })
}
Err(JobError::Terminal(msg)) => {
tracing::Span::current().record("otel.status_code", "ERROR");
error!(
job_id = job.id,
kind = %job.kind,
error = %msg,
"Job failed terminally"
);
let result = sqlx::query(
r#"
UPDATE awa.jobs
SET state = 'failed',
finalized_at = now(),
errors = errors || $2::jsonb,
progress = $4
WHERE id = $1 AND state = 'running' AND run_lease = $3
"#,
)
.bind(job.id)
.bind(serde_json::json!({
"error": msg.to_string(),
"attempt": job.attempt,
"at": chrono::Utc::now().to_rfc3339(),
"terminal": true
}))
.bind(job.run_lease)
.bind(&progress_snapshot)
.execute(pool)
.await?;
if result.rows_affected() == 0 {
warn!(
job_id = job.id,
"Job already rescued/cancelled, terminal failure ignored"
);
return Ok(CompletionOutcome::IgnoredStale);
}
if needs_event {
let updated_job: JobRow = sqlx::query_as("SELECT * FROM awa.jobs WHERE id = $1")
.bind(job.id)
.fetch_one(pool)
.await?;
Ok(CompletionOutcome::Applied {
event: Some(UntypedJobEvent::Exhausted {
job: updated_job,
error: msg.clone(),
attempt: job.attempt,
}),
})
} else {
Ok(CompletionOutcome::Applied { event: None })
}
}
Err(JobError::Retryable(err)) => {
let error_msg = err.to_string();
if job.attempt >= job.max_attempts {
tracing::Span::current().record("otel.status_code", "ERROR");
error!(
job_id = job.id,
kind = %job.kind,
attempt = job.attempt,
max_attempts = job.max_attempts,
error = %error_msg,
"Job failed (max attempts exhausted)"
);
let result = sqlx::query(
r#"
UPDATE awa.jobs
SET state = 'failed',
finalized_at = now(),
errors = errors || $2::jsonb,
progress = $4
WHERE id = $1 AND state = 'running' AND run_lease = $3
"#,
)
.bind(job.id)
.bind(serde_json::json!({
"error": error_msg,
"attempt": job.attempt,
"at": chrono::Utc::now().to_rfc3339()
}))
.bind(job.run_lease)
.bind(&progress_snapshot)
.execute(pool)
.await?;
if result.rows_affected() == 0 {
warn!(
job_id = job.id,
"Job already rescued/cancelled, failure ignored"
);
return Ok(CompletionOutcome::IgnoredStale);
}
if needs_event {
let updated_job: JobRow =
sqlx::query_as("SELECT * FROM awa.jobs WHERE id = $1")
.bind(job.id)
.fetch_one(pool)
.await?;
Ok(CompletionOutcome::Applied {
event: Some(UntypedJobEvent::Exhausted {
job: updated_job,
error: error_msg,
attempt: job.attempt,
}),
})
} else {
Ok(CompletionOutcome::Applied { event: None })
}
} else {
warn!(
job_id = job.id,
kind = %job.kind,
attempt = job.attempt,
error = %error_msg,
"Job failed (will retry)"
);
let result = sqlx::query(
r#"
UPDATE awa.jobs
SET state = 'retryable',
run_at = now() + awa.backoff_duration($2, $3),
finalized_at = now(),
heartbeat_at = NULL,
deadline_at = NULL,
errors = errors || $4::jsonb,
progress = $6
WHERE id = $1 AND state = 'running' AND run_lease = $5
"#,
)
.bind(job.id)
.bind(job.attempt)
.bind(job.max_attempts)
.bind(serde_json::json!({
"error": error_msg,
"attempt": job.attempt,
"at": chrono::Utc::now().to_rfc3339()
}))
.bind(job.run_lease)
.bind(&progress_snapshot)
.execute(pool)
.await?;
if result.rows_affected() == 0 {
warn!(
job_id = job.id,
"Job already rescued/cancelled, retry ignored"
);
return Ok(CompletionOutcome::IgnoredStale);
}
if needs_event {
let updated_job: JobRow =
sqlx::query_as("SELECT * FROM awa.jobs WHERE id = $1")
.bind(job.id)
.fetch_one(pool)
.await?;
Ok(CompletionOutcome::Applied {
event: Some(UntypedJobEvent::Retried {
job: updated_job.clone(),
error: error_msg,
attempt: job.attempt,
next_run_at: updated_job.run_at,
}),
})
} else {
Ok(CompletionOutcome::Applied { event: None })
}
}
}
}
}
async fn dispatch_lifecycle_event(
handlers: &HashMap<String, Vec<BoxedUntypedEventHandler>>,
kind: &str,
event: UntypedJobEvent,
) {
if let Some(handlers) = handlers.get(kind) {
for handler in handlers {
let handler = handler.clone();
let event = event.clone();
let result = tokio::spawn(async move {
(handler)(event).await;
})
.await;
if let Err(err) = result {
tracing::warn!(
kind,
error = %err,
"Lifecycle event handler panicked"
);
}
}
}
}
async fn direct_complete_job(pool: &PgPool, job: &JobRow) -> Result<bool, AwaError> {
let result = sqlx::query(
r#"
UPDATE awa.jobs_hot
SET state = 'completed',
finalized_at = now(),
progress = NULL
WHERE id = $1 AND state = 'running' AND run_lease = $2
"#,
)
.bind(job.id)
.bind(job.run_lease)
.execute(pool)
.await?;
Ok(result.rows_affected() > 0)
}