use crate::completion::CompletionBatcherHandle;
use crate::context::{CallbackGuard, JobContext};
use crate::events::{BoxedUntypedEventHandler, UntypedJobEvent};
use crate::runtime::{InFlightMap, InFlightState, ProgressState};
use crate::storage::{QueueStorageRuntime, RuntimeStorage};
use awa_model::{AwaError, ClaimedEntry, ClaimedRuntimeJob, JobRow, JobState};
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, Clone, Default)]
pub struct DlqPolicy {
pub enabled_default: bool,
pub overrides: Arc<HashMap<String, bool>>,
}
impl DlqPolicy {
pub fn new(enabled_default: bool, overrides: HashMap<String, bool>) -> Self {
Self {
enabled_default,
overrides: Arc::new(overrides),
}
}
pub fn enabled_for(&self, queue: &str) -> bool {
self.overrides
.get(queue)
.copied()
.unwrap_or(self.enabled_default)
}
}
#[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>,
terminal: bool,
},
IgnoredStale,
}
#[derive(Debug, Clone)]
pub(crate) struct DispatchedJob {
pub job: JobRow,
pub queue_storage_claim: Option<ClaimedEntry>,
pub queue_storage_unique_states: Option<String>,
}
pub struct JobExecutor {
pool: PgPool,
workers: Arc<HashMap<String, BoxedWorker>>,
lifecycle_handlers: Arc<HashMap<String, Vec<BoxedUntypedEventHandler>>>,
enqueue_specs: Arc<
HashMap<
crate::enqueue_specs::Outcome,
HashMap<String, Vec<crate::enqueue_specs::BoxedEnqueueSpec>>,
>,
>,
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,
storage: RuntimeStorage,
dlq_policy: DlqPolicy,
}
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>>>,
enqueue_specs: Arc<
HashMap<
crate::enqueue_specs::Outcome,
HashMap<String, Vec<crate::enqueue_specs::BoxedEnqueueSpec>>,
>,
>,
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,
storage: RuntimeStorage,
dlq_policy: DlqPolicy,
) -> Self {
Self {
pool,
workers,
lifecycle_handlers,
enqueue_specs,
in_flight,
queue_in_flight,
state,
metrics,
completion_batcher,
storage,
dlq_policy,
}
}
pub(crate) fn execute_task(
&self,
dispatched: DispatchedJob,
cancel: Arc<AtomicBool>,
) -> impl std::future::Future<Output = ()> + Send + 'static {
let job = dispatched.job;
let queue_storage_claim = dispatched.queue_storage_claim;
let queue_storage_unique_states = dispatched.queue_storage_unique_states;
let pool = self.pool.clone();
let workers = self.workers.clone();
let lifecycle_handlers = self.lifecycle_handlers.clone();
let enqueue_specs = self.enqueue_specs.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 storage = self.storage.clone();
let dlq_policy = self.dlq_policy.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 has_lifecycle_handlers = lifecycle_handlers.contains_key(&job_kind);
let start = std::time::Instant::now();
let ctx = JobContext::new(
job.clone(),
cancel,
state,
pool.clone(),
storage.clone(),
progress_state.clone(),
);
let result = match workers.get(&job.kind) {
Some(worker) => {
if has_lifecycle_handlers {
let started_handlers = lifecycle_handlers.clone();
let started_kind = job_kind.clone();
let started_job = job.clone();
tokio::spawn(async move {
dispatch_lifecycle_event(
&started_handlers,
&started_kind,
UntypedJobEvent::Started { job: started_job },
)
.await;
});
}
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()
};
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);
let dlq_enabled = dlq_policy.enabled_for(&job_queue);
tokio::spawn(async move {
let outcome = complete_job(
&pool,
&job,
queue_storage_claim.as_ref(),
queue_storage_unique_states.as_deref(),
&result,
&completion_batcher,
progress_snapshot,
duration,
has_lifecycle_handlers,
&enqueue_specs,
&storage,
dlq_enabled,
&metrics,
)
.await;
match &outcome {
Ok(CompletionOutcome::Applied { terminal, .. }) => {
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(_)) => {
if *terminal {
metrics.record_job_failed(&job_kind, &job_queue, true);
} else {
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(_)) => {
if *terminal {
metrics.record_job_failed(&job_kind, &job_queue, true);
} else {
metrics.record_job_retried(&job_kind, &job_queue);
}
}
}
}
Ok(CompletionOutcome::IgnoredStale) => {}
Err(err) => {
error!(job_id, error = %err, "Failed to complete job");
}
}
if let Ok(CompletionOutcome::Applied {
event: Some(event), ..
}) = outcome
{
dispatch_lifecycle_event(&lifecycle_handlers, &job_kind, event).await;
}
});
}
.instrument(span)
}
}
#[allow(clippy::too_many_arguments)]
async fn complete_job(
pool: &PgPool,
job: &JobRow,
queue_storage_claim: Option<&ClaimedEntry>,
queue_storage_unique_states: Option<&str>,
result: &Result<JobResult, JobError>,
completion_batcher: &CompletionBatcherHandle,
progress_snapshot: Option<serde_json::Value>,
duration: Duration,
needs_event: bool,
enqueue_specs: &Arc<
HashMap<
crate::enqueue_specs::Outcome,
HashMap<String, Vec<crate::enqueue_specs::BoxedEnqueueSpec>>,
>,
>,
storage: &RuntimeStorage,
dlq_enabled: bool,
metrics: &crate::metrics::AwaMetrics,
) -> Result<CompletionOutcome, AwaError> {
match storage {
RuntimeStorage::Canonical => {
complete_job_canonical(
pool,
job,
result,
completion_batcher,
progress_snapshot,
duration,
needs_event,
enqueue_specs,
dlq_enabled,
metrics,
)
.await
}
RuntimeStorage::QueueStorage(runtime) => {
complete_job_queue_storage(
runtime,
pool,
job,
queue_storage_claim,
queue_storage_unique_states,
result,
completion_batcher,
progress_snapshot,
duration,
needs_event,
enqueue_specs,
dlq_enabled,
metrics,
)
.await
}
}
}
#[allow(clippy::too_many_arguments)]
async fn complete_job_canonical(
pool: &PgPool,
job: &JobRow,
result: &Result<JobResult, JobError>,
completion_batcher: &CompletionBatcherHandle,
progress_snapshot: Option<serde_json::Value>,
duration: Duration,
needs_event: bool,
enqueue_specs: &Arc<
HashMap<
crate::enqueue_specs::Outcome,
HashMap<String, Vec<crate::enqueue_specs::BoxedEnqueueSpec>>,
>,
>,
_dlq_enabled: bool,
_metrics: &crate::metrics::AwaMetrics,
) -> 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 kind_specs = enqueue_specs
.get(&crate::enqueue_specs::Outcome::Completed)
.and_then(|by_kind| by_kind.get(&job.kind))
.cloned();
if let Some(specs) = kind_specs.filter(|s| !s.is_empty()) {
let outcome = complete_canonical_with_followups(pool, job, &specs).await?;
return match outcome {
None => {
warn!(
job_id = job.id,
"Job already rescued/cancelled, completion ignored"
);
Ok(CompletionOutcome::IgnoredStale)
}
Some(updated_job) => {
let event = if needs_event {
Some(UntypedJobEvent::Completed {
job: updated_job,
duration,
})
} else {
None
};
Ok(CompletionOutcome::Applied {
event,
terminal: false,
})
}
};
}
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,
}),
terminal: false,
})
} else {
Ok(CompletionOutcome::Applied {
event: None,
terminal: false,
})
}
}
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 kind_specs = enqueue_specs
.get(&crate::enqueue_specs::Outcome::Retried)
.and_then(|by_kind| by_kind.get(&job.kind))
.cloned();
if let Some(specs) = kind_specs.filter(|s| !s.is_empty()) {
let result = retry_after_canonical_with_followups(
pool,
job,
seconds,
progress_snapshot.as_ref(),
&specs,
)
.await?;
return match result {
None => {
warn!(
job_id = job.id,
"Job already rescued/cancelled, retry ignored"
);
Ok(CompletionOutcome::IgnoredStale)
}
Some(updated_job) => {
let event = if needs_event {
Some(UntypedJobEvent::Retried {
job: updated_job.clone(),
error: String::new(),
attempt: updated_job.attempt,
next_run_at: updated_job.run_at,
})
} else {
None
};
Ok(CompletionOutcome::Applied {
event,
terminal: false,
})
}
};
}
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,
}),
terminal: false,
})
} else {
Ok(CompletionOutcome::Applied {
event: None,
terminal: false,
})
}
}
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,
terminal: false,
})
}
Ok(JobResult::Cancel(reason)) => {
tracing::Span::current().record("otel.status_code", "OK");
info!(
job_id = job.id,
kind = %job.kind,
reason = %reason,
"Job cancelled by handler"
);
let kind_specs = enqueue_specs
.get(&crate::enqueue_specs::Outcome::Cancelled)
.and_then(|by_kind| by_kind.get(&job.kind))
.cloned();
let outcome_ctx = crate::enqueue_specs::OutcomeContext::Cancelled {
reason: reason.clone(),
};
if let Some(specs) = kind_specs.filter(|s| !s.is_empty()) {
let result = cancel_canonical_with_followups(
pool,
job,
reason,
progress_snapshot.as_ref(),
&specs,
&outcome_ctx,
)
.await?;
return match result {
None => {
warn!(
job_id = job.id,
"Job already rescued/cancelled, cancel ignored"
);
Ok(CompletionOutcome::IgnoredStale)
}
Some(updated_job) => {
let event = if needs_event {
Some(UntypedJobEvent::Cancelled {
job: updated_job,
reason: reason.clone(),
})
} else {
None
};
Ok(CompletionOutcome::Applied {
event,
terminal: false,
})
}
};
}
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(),
}),
terminal: false,
})
} else {
Ok(CompletionOutcome::Applied {
event: None,
terminal: false,
})
}
}
Ok(JobResult::WaitForCallback(_guard)) => {
info!(
job_id = job.id,
kind = %job.kind,
"Job waiting for external callback"
);
let kind_specs = enqueue_specs
.get(&crate::enqueue_specs::Outcome::WaitingForCallback)
.and_then(|by_kind| by_kind.get(&job.kind))
.cloned();
if let Some(specs) = kind_specs.filter(|s| !s.is_empty()) {
let parked =
park_canonical_with_followups(pool, job, progress_snapshot.as_ref(), &specs)
.await?;
if let Some(parked_job) = parked {
let event = if needs_event {
Some(UntypedJobEvent::WaitingForCallback { job: parked_job })
} else {
None
};
return Ok(CompletionOutcome::Applied {
event,
terminal: false,
});
}
}
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<(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,
terminal: false,
});
}
Some((_, None)) => {
error!(
job_id = job.id,
"WaitForCallback returned without calling register_callback"
);
let result = 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?;
if result.rows_affected() == 0 {
return Ok(CompletionOutcome::IgnoredStale);
}
return Ok(CompletionOutcome::Applied {
event: None,
terminal: true,
});
}
_ => {
warn!(
job_id = job.id,
"Job already rescued/cancelled, wait-for-callback ignored"
);
return Ok(CompletionOutcome::IgnoredStale);
}
}
}
let event = if needs_event {
let parked_job: JobRow = sqlx::query_as("SELECT * FROM awa.jobs WHERE id = $1")
.bind(job.id)
.fetch_one(pool)
.await?;
Some(UntypedJobEvent::WaitingForCallback { job: parked_job })
} else {
None
};
Ok(CompletionOutcome::Applied {
event,
terminal: false,
})
}
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 kind_specs = enqueue_specs
.get(&crate::enqueue_specs::Outcome::Exhausted)
.and_then(|by_kind| by_kind.get(&job.kind))
.cloned();
if let Some(specs) = kind_specs.filter(|s| !s.is_empty()) {
let result = terminal_canonical_with_followups(
pool,
job,
msg,
progress_snapshot.as_ref(),
&specs,
)
.await?;
return match result {
None => {
warn!(
job_id = job.id,
"Job already rescued/cancelled, terminal failure ignored"
);
Ok(CompletionOutcome::IgnoredStale)
}
Some(updated_job) => {
let event = if needs_event {
Some(UntypedJobEvent::Exhausted {
job: updated_job,
error: msg.clone(),
attempt: job.attempt,
})
} else {
None
};
Ok(CompletionOutcome::Applied {
event,
terminal: true,
})
}
};
}
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,
}),
terminal: true,
})
} else {
Ok(CompletionOutcome::Applied {
event: None,
terminal: true,
})
}
}
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 kind_specs = enqueue_specs
.get(&crate::enqueue_specs::Outcome::Exhausted)
.and_then(|by_kind| by_kind.get(&job.kind))
.cloned();
if let Some(specs) = kind_specs.filter(|s| !s.is_empty()) {
let result = exhaust_canonical_with_followups(
pool,
job,
&error_msg,
progress_snapshot.as_ref(),
&specs,
)
.await?;
return match result {
None => {
warn!(
job_id = job.id,
"Job already rescued/cancelled, failure ignored"
);
Ok(CompletionOutcome::IgnoredStale)
}
Some(updated_job) => {
let event = if needs_event {
Some(UntypedJobEvent::Exhausted {
job: updated_job,
error: error_msg,
attempt: job.attempt,
})
} else {
None
};
Ok(CompletionOutcome::Applied {
event,
terminal: true,
})
}
};
}
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,
}),
terminal: true,
})
} else {
Ok(CompletionOutcome::Applied {
event: None,
terminal: true,
})
}
} else {
warn!(
job_id = job.id,
kind = %job.kind,
attempt = job.attempt,
error = %error_msg,
"Job failed (will retry)"
);
let kind_specs = enqueue_specs
.get(&crate::enqueue_specs::Outcome::Retried)
.and_then(|by_kind| by_kind.get(&job.kind))
.cloned();
if let Some(specs) = kind_specs.filter(|s| !s.is_empty()) {
let result = retry_backoff_canonical_with_followups(
pool,
job,
&error_msg,
progress_snapshot.as_ref(),
&specs,
)
.await?;
return match result {
None => {
warn!(
job_id = job.id,
"Job already rescued/cancelled, retry ignored"
);
Ok(CompletionOutcome::IgnoredStale)
}
Some(updated_job) => {
let event = if needs_event {
Some(UntypedJobEvent::Retried {
job: updated_job.clone(),
error: error_msg,
attempt: job.attempt,
next_run_at: updated_job.run_at,
})
} else {
None
};
Ok(CompletionOutcome::Applied {
event,
terminal: false,
})
}
};
}
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,
}),
terminal: false,
})
} else {
Ok(CompletionOutcome::Applied {
event: None,
terminal: false,
})
}
}
}
}
}
#[allow(clippy::too_many_arguments)]
async fn complete_job_queue_storage(
runtime: &QueueStorageRuntime,
pool: &PgPool,
job: &JobRow,
queue_storage_claim: Option<&ClaimedEntry>,
queue_storage_unique_states: Option<&str>,
result: &Result<JobResult, JobError>,
completion_batcher: &CompletionBatcherHandle,
progress_snapshot: Option<serde_json::Value>,
duration: Duration,
needs_event: bool,
enqueue_specs: &Arc<
HashMap<
crate::enqueue_specs::Outcome,
HashMap<String, Vec<crate::enqueue_specs::BoxedEnqueueSpec>>,
>,
>,
dlq_enabled: bool,
metrics: &crate::metrics::AwaMetrics,
) -> 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 kind_specs = enqueue_specs
.get(&crate::enqueue_specs::Outcome::Completed)
.and_then(|by_kind| by_kind.get(&job.kind))
.cloned();
if let Some(specs) = kind_specs.filter(|s| !s.is_empty()) {
let outcome = complete_queue_storage_with_followups(
runtime,
pool,
job,
queue_storage_claim,
queue_storage_unique_states,
&specs,
)
.await?;
return match outcome {
None => {
warn!(
job_id = job.id,
"Job already rescued/cancelled, completion ignored"
);
Ok(CompletionOutcome::IgnoredStale)
}
Some(updated_job) => {
let event = if needs_event {
Some(UntypedJobEvent::Completed {
job: updated_job,
duration,
})
} else {
None
};
Ok(CompletionOutcome::Applied {
event,
terminal: false,
})
}
};
}
let updated = match match queue_storage_claim {
Some(claim) => {
completion_batcher
.complete_runtime_job(ClaimedRuntimeJob {
claim: claim.clone(),
job: job.clone(),
unique_states: queue_storage_unique_states
.map(std::string::ToString::to_string),
})
.await
}
None => 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_queue_storage(
runtime,
pool,
job,
queue_storage_claim,
queue_storage_unique_states,
)
.await?
}
};
if !updated {
warn!(
job_id = job.id,
"Job already rescued/cancelled, completion ignored"
);
return Ok(CompletionOutcome::IgnoredStale);
}
if needs_event {
let updated_job =
runtime
.store
.load_job(pool, job.id)
.await?
.unwrap_or_else(|| {
let mut completed_job = job.clone();
completed_job.state = JobState::Completed;
completed_job.finalized_at = Some(chrono::Utc::now());
completed_job.progress = None;
completed_job
});
Ok(CompletionOutcome::Applied {
event: Some(UntypedJobEvent::Completed {
job: updated_job,
duration,
}),
terminal: false,
})
} else {
Ok(CompletionOutcome::Applied {
event: None,
terminal: false,
})
}
}
Ok(JobResult::RetryAfter(retry_duration)) => {
info!(
job_id = job.id,
kind = %job.kind,
retry_after_secs = retry_duration.as_secs_f64(),
"Job requested retry after duration"
);
let kind_specs = enqueue_specs
.get(&crate::enqueue_specs::Outcome::Retried)
.and_then(|by_kind| by_kind.get(&job.kind))
.cloned();
if let Some(specs) = kind_specs.filter(|s| !s.is_empty()) {
let result = retry_after_queue_storage_with_followups(
runtime,
pool,
job,
*retry_duration,
progress_snapshot.clone(),
&specs,
)
.await?;
return match result {
None => {
warn!(
job_id = job.id,
"Job already rescued/cancelled, retry ignored"
);
Ok(CompletionOutcome::IgnoredStale)
}
Some(updated_job) => {
let event = if needs_event {
Some(UntypedJobEvent::Retried {
job: updated_job.clone(),
error: String::new(),
attempt: updated_job.attempt,
next_run_at: updated_job.run_at,
})
} else {
None
};
Ok(CompletionOutcome::Applied {
event,
terminal: false,
})
}
};
}
let Some(updated_job) = runtime
.store
.retry_after(
pool,
job.id,
job.run_lease,
*retry_duration,
progress_snapshot.clone(),
)
.await?
else {
warn!(
job_id = job.id,
"Job already rescued/cancelled, retry ignored"
);
return Ok(CompletionOutcome::IgnoredStale);
};
if needs_event {
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,
}),
terminal: false,
})
} else {
Ok(CompletionOutcome::Applied {
event: None,
terminal: false,
})
}
}
Ok(JobResult::Snooze(snooze_duration)) => {
info!(
job_id = job.id,
kind = %job.kind,
snooze_secs = snooze_duration.as_secs_f64(),
"Job snoozed (attempt not incremented)"
);
let updated = runtime
.store
.snooze(
pool,
job.id,
job.run_lease,
*snooze_duration,
progress_snapshot.clone(),
)
.await?;
if updated.is_none() {
warn!(
job_id = job.id,
"Job already rescued/cancelled, snooze ignored"
);
return Ok(CompletionOutcome::IgnoredStale);
}
Ok(CompletionOutcome::Applied {
event: None,
terminal: false,
})
}
Ok(JobResult::Cancel(reason)) => {
info!(
job_id = job.id,
kind = %job.kind,
reason = %reason,
"Job cancelled by handler"
);
let kind_specs = enqueue_specs
.get(&crate::enqueue_specs::Outcome::Cancelled)
.and_then(|by_kind| by_kind.get(&job.kind))
.cloned();
if let Some(specs) = kind_specs.filter(|s| !s.is_empty()) {
let result = cancel_queue_storage_with_followups(
runtime,
pool,
job,
reason,
progress_snapshot.clone(),
&specs,
)
.await?;
return match result {
None => {
warn!(
job_id = job.id,
"Job already rescued/cancelled, cancel ignored"
);
Ok(CompletionOutcome::IgnoredStale)
}
Some(updated_job) => {
let event = if needs_event {
Some(UntypedJobEvent::Cancelled {
job: updated_job,
reason: reason.clone(),
})
} else {
None
};
Ok(CompletionOutcome::Applied {
event,
terminal: false,
})
}
};
}
let Some(updated_job) = runtime
.store
.cancel_running(
pool,
job.id,
job.run_lease,
reason,
progress_snapshot.clone(),
)
.await?
else {
warn!(
job_id = job.id,
"Job already rescued/cancelled, cancel ignored"
);
return Ok(CompletionOutcome::IgnoredStale);
};
if needs_event {
Ok(CompletionOutcome::Applied {
event: Some(UntypedJobEvent::Cancelled {
job: updated_job,
reason: reason.clone(),
}),
terminal: false,
})
} else {
Ok(CompletionOutcome::Applied {
event: None,
terminal: false,
})
}
}
Ok(JobResult::WaitForCallback(guard)) => {
info!(
job_id = job.id,
kind = %job.kind,
"Job waiting for external callback"
);
let kind_specs = enqueue_specs
.get(&crate::enqueue_specs::Outcome::WaitingForCallback)
.and_then(|by_kind| by_kind.get(&job.kind))
.cloned();
if let Some(specs) = kind_specs.filter(|s| !s.is_empty()) {
let parked =
park_queue_storage_with_followups(runtime, pool, job, guard.id(), &specs)
.await?;
if let Some(parked_job) = parked {
let event = if needs_event {
Some(UntypedJobEvent::WaitingForCallback { job: parked_job })
} else {
None
};
return Ok(CompletionOutcome::Applied {
event,
terminal: false,
});
}
}
let entered = runtime
.store
.enter_callback_wait(pool, job.id, job.run_lease, guard.id())
.await?;
if !entered {
let current = runtime.store.load_job(pool, job.id).await?;
match current {
Some(current) if current.state.is_terminal() => {
info!(
job_id = job.id,
state = %current.state,
"Job already completed by racing callback"
);
return Ok(CompletionOutcome::Applied {
event: None,
terminal: false,
});
}
Some(current)
if current.state == JobState::Running && current.callback_id.is_none() =>
{
error!(
job_id = job.id,
"WaitForCallback returned without calling register_callback"
);
let failed = if dlq_enabled {
let failed = runtime
.store
.fail_to_dlq(
pool,
job.id,
job.run_lease,
"wait_for_callback_contract_violation",
"WaitForCallback returned without calling register_callback",
progress_snapshot.clone(),
)
.await?;
if failed.is_some() {
metrics.record_dlq_moved(
&job.kind,
&job.queue,
"wait_for_callback_contract_violation",
);
}
failed
} else {
runtime
.store
.fail_terminal(
pool,
job.id,
job.run_lease,
"WaitForCallback returned without calling register_callback",
progress_snapshot.clone(),
)
.await?
};
if failed.is_none() {
return Ok(CompletionOutcome::IgnoredStale);
}
return Ok(CompletionOutcome::Applied {
event: None,
terminal: true,
});
}
_ => {
warn!(
job_id = job.id,
"Job already rescued/cancelled, wait-for-callback ignored"
);
return Ok(CompletionOutcome::IgnoredStale);
}
}
}
let event = if needs_event {
let parked_job = runtime
.store
.load_job(pool, job.id)
.await?
.unwrap_or_else(|| {
let mut parked = job.clone();
parked.state = JobState::WaitingExternal;
parked
});
Some(UntypedJobEvent::WaitingForCallback { job: parked_job })
} else {
None
};
Ok(CompletionOutcome::Applied {
event,
terminal: false,
})
}
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 kind_specs = enqueue_specs
.get(&crate::enqueue_specs::Outcome::Exhausted)
.and_then(|by_kind| by_kind.get(&job.kind))
.cloned();
if let Some(specs) = kind_specs.filter(|s| !s.is_empty()) {
let result = fail_queue_storage_with_followups(
runtime,
pool,
job,
"terminal_error",
msg,
progress_snapshot.clone(),
dlq_enabled,
metrics,
&specs,
)
.await?;
return match result {
None => {
warn!(
job_id = job.id,
"Job already rescued/cancelled, terminal failure ignored"
);
Ok(CompletionOutcome::IgnoredStale)
}
Some(updated_job) => {
let event = if needs_event {
Some(UntypedJobEvent::Exhausted {
job: updated_job,
error: msg.clone(),
attempt: job.attempt,
})
} else {
None
};
Ok(CompletionOutcome::Applied {
event,
terminal: true,
})
}
};
}
let updated_job = if dlq_enabled {
let moved = runtime
.store
.fail_to_dlq(
pool,
job.id,
job.run_lease,
"terminal_error",
msg,
progress_snapshot.clone(),
)
.await?;
if moved.is_some() {
metrics.record_dlq_moved(&job.kind, &job.queue, "terminal_error");
}
moved
} else {
runtime
.store
.fail_terminal(pool, job.id, job.run_lease, msg, progress_snapshot.clone())
.await?
};
let Some(updated_job) = updated_job else {
warn!(
job_id = job.id,
"Job already rescued/cancelled, terminal failure ignored"
);
return Ok(CompletionOutcome::IgnoredStale);
};
if needs_event {
Ok(CompletionOutcome::Applied {
event: Some(UntypedJobEvent::Exhausted {
job: updated_job,
error: msg.clone(),
attempt: job.attempt,
}),
terminal: true,
})
} else {
Ok(CompletionOutcome::Applied {
event: None,
terminal: true,
})
}
}
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 kind_specs = enqueue_specs
.get(&crate::enqueue_specs::Outcome::Exhausted)
.and_then(|by_kind| by_kind.get(&job.kind))
.cloned();
if let Some(specs) = kind_specs.filter(|s| !s.is_empty()) {
let result = fail_queue_storage_with_followups(
runtime,
pool,
job,
"max_attempts_exhausted",
&error_msg,
progress_snapshot.clone(),
dlq_enabled,
metrics,
&specs,
)
.await?;
return match result {
None => {
warn!(
job_id = job.id,
"Job already rescued/cancelled, failure ignored"
);
Ok(CompletionOutcome::IgnoredStale)
}
Some(updated_job) => {
let event = if needs_event {
Some(UntypedJobEvent::Exhausted {
job: updated_job,
error: error_msg,
attempt: job.attempt,
})
} else {
None
};
Ok(CompletionOutcome::Applied {
event,
terminal: true,
})
}
};
}
let updated_job = if dlq_enabled {
let moved = runtime
.store
.fail_to_dlq(
pool,
job.id,
job.run_lease,
"max_attempts_exhausted",
&error_msg,
progress_snapshot.clone(),
)
.await?;
if moved.is_some() {
metrics.record_dlq_moved(&job.kind, &job.queue, "max_attempts_exhausted");
}
moved
} else {
runtime
.store
.fail_terminal(
pool,
job.id,
job.run_lease,
&error_msg,
progress_snapshot.clone(),
)
.await?
};
let Some(updated_job) = updated_job else {
warn!(
job_id = job.id,
"Job already rescued/cancelled, failure ignored"
);
return Ok(CompletionOutcome::IgnoredStale);
};
if needs_event {
Ok(CompletionOutcome::Applied {
event: Some(UntypedJobEvent::Exhausted {
job: updated_job,
error: error_msg,
attempt: job.attempt,
}),
terminal: true,
})
} else {
Ok(CompletionOutcome::Applied {
event: None,
terminal: true,
})
}
} else {
warn!(
job_id = job.id,
kind = %job.kind,
attempt = job.attempt,
error = %error_msg,
"Job failed (will retry)"
);
let kind_specs = enqueue_specs
.get(&crate::enqueue_specs::Outcome::Retried)
.and_then(|by_kind| by_kind.get(&job.kind))
.cloned();
if let Some(specs) = kind_specs.filter(|s| !s.is_empty()) {
let result = retry_backoff_queue_storage_with_followups(
runtime,
pool,
job,
&error_msg,
progress_snapshot.clone(),
&specs,
)
.await?;
return match result {
None => {
warn!(
job_id = job.id,
"Job already rescued/cancelled, retry ignored"
);
Ok(CompletionOutcome::IgnoredStale)
}
Some(updated_job) => {
let event = if needs_event {
Some(UntypedJobEvent::Retried {
job: updated_job.clone(),
error: error_msg,
attempt: job.attempt,
next_run_at: updated_job.run_at,
})
} else {
None
};
Ok(CompletionOutcome::Applied {
event,
terminal: false,
})
}
};
}
let Some(updated_job) = runtime
.store
.fail_retryable(
pool,
job.id,
job.run_lease,
&error_msg,
progress_snapshot.clone(),
)
.await?
else {
warn!(
job_id = job.id,
"Job already rescued/cancelled, retry ignored"
);
return Ok(CompletionOutcome::IgnoredStale);
};
if needs_event {
Ok(CompletionOutcome::Applied {
event: Some(UntypedJobEvent::Retried {
job: updated_job.clone(),
error: error_msg,
attempt: job.attempt,
next_run_at: updated_job.run_at,
}),
terminal: false,
})
} else {
Ok(CompletionOutcome::Applied {
event: None,
terminal: false,
})
}
}
}
}
}
#[allow(clippy::explicit_auto_deref)]
async fn complete_queue_storage_with_followups(
runtime: &QueueStorageRuntime,
pool: &PgPool,
job: &JobRow,
queue_storage_claim: Option<&ClaimedEntry>,
queue_storage_unique_states: Option<&str>,
specs: &[crate::enqueue_specs::BoxedEnqueueSpec],
) -> Result<Option<JobRow>, AwaError> {
let Some(claim) = queue_storage_claim else {
warn!(
job_id = job.id,
"queue-storage completion with follow-up specs but no claim — \
treating as stale"
);
return Ok(None);
};
let runtime_job = ClaimedRuntimeJob {
claim: claim.clone(),
job: job.clone(),
unique_states: queue_storage_unique_states.map(std::string::ToString::to_string),
};
let mut tx = pool.begin().await?;
let updated = runtime
.store
.complete_runtime_batch_slow_in_tx(&mut tx, std::slice::from_ref(&runtime_job))
.await?;
if updated.is_empty() {
tx.rollback().await?;
return Ok(None);
}
let mut updated_job = job.clone();
updated_job.state = JobState::Completed;
updated_job.finalized_at = Some(chrono::Utc::now());
updated_job.progress = None;
crate::enqueue_specs::dispatch_specs_in_tx(&mut tx, &updated_job, specs, None).await?;
tx.commit().await?;
Ok(Some(updated_job))
}
async fn cancel_queue_storage_with_followups(
runtime: &QueueStorageRuntime,
pool: &PgPool,
job: &JobRow,
reason: &str,
progress_snapshot: Option<serde_json::Value>,
specs: &[crate::enqueue_specs::BoxedEnqueueSpec],
) -> Result<Option<JobRow>, AwaError> {
let mut tx = pool.begin().await?;
let Some(updated_job) = runtime
.store
.cancel_running_in_tx(&mut tx, job.id, job.run_lease, reason, progress_snapshot)
.await?
else {
tx.rollback().await?;
return Ok(None);
};
let outcome_ctx = crate::enqueue_specs::OutcomeContext::Cancelled {
reason: reason.to_string(),
};
crate::enqueue_specs::dispatch_specs_in_tx(&mut tx, &updated_job, specs, Some(&outcome_ctx))
.await?;
tx.commit().await?;
Ok(Some(updated_job))
}
async fn retry_after_queue_storage_with_followups(
runtime: &QueueStorageRuntime,
pool: &PgPool,
job: &JobRow,
retry_duration: Duration,
progress_snapshot: Option<serde_json::Value>,
specs: &[crate::enqueue_specs::BoxedEnqueueSpec],
) -> Result<Option<JobRow>, AwaError> {
let mut tx = pool.begin().await?;
let Some(updated_job) = runtime
.store
.retry_after_in_tx(
&mut tx,
job.id,
job.run_lease,
retry_duration,
progress_snapshot,
)
.await?
else {
tx.rollback().await?;
return Ok(None);
};
let outcome_ctx = crate::enqueue_specs::OutcomeContext::Retried {
error: String::new(),
attempt: updated_job.attempt,
next_run_at: updated_job.run_at,
};
crate::enqueue_specs::dispatch_specs_in_tx(&mut tx, &updated_job, specs, Some(&outcome_ctx))
.await?;
tx.commit().await?;
Ok(Some(updated_job))
}
async fn retry_backoff_queue_storage_with_followups(
runtime: &QueueStorageRuntime,
pool: &PgPool,
job: &JobRow,
error_msg: &str,
progress_snapshot: Option<serde_json::Value>,
specs: &[crate::enqueue_specs::BoxedEnqueueSpec],
) -> Result<Option<JobRow>, AwaError> {
let mut tx = pool.begin().await?;
let Some(updated_job) = runtime
.store
.fail_retryable_in_tx(&mut tx, job.id, job.run_lease, error_msg, progress_snapshot)
.await?
else {
tx.rollback().await?;
return Ok(None);
};
let outcome_ctx = crate::enqueue_specs::OutcomeContext::Retried {
error: error_msg.to_string(),
attempt: job.attempt,
next_run_at: updated_job.run_at,
};
crate::enqueue_specs::dispatch_specs_in_tx(&mut tx, &updated_job, specs, Some(&outcome_ctx))
.await?;
tx.commit().await?;
Ok(Some(updated_job))
}
#[allow(clippy::too_many_arguments)]
async fn fail_queue_storage_with_followups(
runtime: &QueueStorageRuntime,
pool: &PgPool,
job: &JobRow,
dlq_reason: &str,
error_msg: &str,
progress_snapshot: Option<serde_json::Value>,
dlq_enabled: bool,
metrics: &crate::metrics::AwaMetrics,
specs: &[crate::enqueue_specs::BoxedEnqueueSpec],
) -> Result<Option<JobRow>, AwaError> {
let mut tx = pool.begin().await?;
let (updated_job, routed_to_dlq) = if dlq_enabled {
let moved = runtime
.store
.fail_to_dlq_in_tx(
&mut tx,
job.id,
job.run_lease,
dlq_reason,
error_msg,
progress_snapshot,
)
.await?;
let routed = moved.is_some();
(moved, routed)
} else {
let moved = runtime
.store
.fail_terminal_in_tx(&mut tx, job.id, job.run_lease, error_msg, progress_snapshot)
.await?;
(moved, false)
};
let Some(updated_job) = updated_job else {
tx.rollback().await?;
return Ok(None);
};
let outcome_ctx = crate::enqueue_specs::OutcomeContext::Exhausted {
error: error_msg.to_string(),
attempt: job.attempt,
};
crate::enqueue_specs::dispatch_specs_in_tx(&mut tx, &updated_job, specs, Some(&outcome_ctx))
.await?;
tx.commit().await?;
if routed_to_dlq {
metrics.record_dlq_moved(&job.kind, &job.queue, dlq_reason);
}
Ok(Some(updated_job))
}
async fn park_queue_storage_with_followups(
runtime: &QueueStorageRuntime,
pool: &PgPool,
job: &JobRow,
callback_id: uuid::Uuid,
specs: &[crate::enqueue_specs::BoxedEnqueueSpec],
) -> Result<Option<JobRow>, AwaError> {
let mut tx = pool.begin().await?;
let entered = runtime
.store
.enter_callback_wait_in_tx(&mut tx, job.id, job.run_lease, callback_id)
.await?;
if !entered {
tx.rollback().await?;
return Ok(None);
}
let parked_job = match runtime
.store
.load_active_lease_in_tx(&mut tx, job.id, job.run_lease)
.await?
{
Some(row) => row,
None => {
let mut parked = job.clone();
parked.state = JobState::WaitingExternal;
parked.heartbeat_at = None;
parked.deadline_at = None;
parked.callback_id = Some(callback_id);
parked
}
};
crate::enqueue_specs::dispatch_specs_in_tx(
&mut tx,
&parked_job,
specs,
Some(&crate::enqueue_specs::OutcomeContext::WaitingForCallback),
)
.await?;
tx.commit().await?;
Ok(Some(parked_job))
}
async fn direct_complete_job_queue_storage(
runtime: &QueueStorageRuntime,
pool: &PgPool,
job: &JobRow,
queue_storage_claim: Option<&ClaimedEntry>,
queue_storage_unique_states: Option<&str>,
) -> Result<bool, AwaError> {
let updated = if let Some(claim) = queue_storage_claim {
let runtime_job = ClaimedRuntimeJob {
claim: claim.clone(),
job: job.clone(),
unique_states: queue_storage_unique_states.map(std::string::ToString::to_string),
};
runtime
.store
.complete_runtime_batch(pool, std::slice::from_ref(&runtime_job))
.await?
} else {
runtime
.store
.complete_job_batch_by_id(pool, &[(job.id, job.run_lease)])
.await?
};
Ok(!updated.is_empty())
}
pub(crate) 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)
}
#[allow(clippy::explicit_auto_deref)]
async fn complete_canonical_with_followups(
pool: &PgPool,
job: &JobRow,
specs: &[crate::enqueue_specs::BoxedEnqueueSpec],
) -> Result<Option<JobRow>, AwaError> {
let mut tx = pool.begin().await?;
let updated_job: Option<JobRow> = sqlx::query_as(
r#"
UPDATE awa.jobs_hot
SET state = 'completed',
finalized_at = now(),
progress = NULL
WHERE id = $1 AND state = 'running' AND run_lease = $2
RETURNING *
"#,
)
.bind(job.id)
.bind(job.run_lease)
.fetch_optional(&mut *tx)
.await?;
let Some(updated_job) = updated_job else {
tx.rollback().await?;
return Ok(None);
};
crate::enqueue_specs::dispatch_specs_in_tx(&mut tx, &updated_job, specs, None).await?;
tx.commit().await?;
Ok(Some(updated_job))
}
async fn cancel_canonical_with_followups(
pool: &PgPool,
job: &JobRow,
reason: &str,
progress_snapshot: Option<&serde_json::Value>,
specs: &[crate::enqueue_specs::BoxedEnqueueSpec],
outcome_ctx: &crate::enqueue_specs::OutcomeContext,
) -> Result<Option<JobRow>, AwaError> {
let mut tx = pool.begin().await?;
let updated_job: Option<JobRow> = sqlx::query_as(
r#"
UPDATE awa.jobs_hot
SET state = 'cancelled',
finalized_at = now(),
errors = errors || $2::jsonb,
progress = $4
WHERE id = $1 AND state = 'running' AND run_lease = $3
RETURNING *
"#,
)
.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)
.fetch_optional(&mut *tx)
.await?;
let Some(updated_job) = updated_job else {
tx.rollback().await?;
return Ok(None);
};
crate::enqueue_specs::dispatch_specs_in_tx(&mut tx, &updated_job, specs, Some(outcome_ctx))
.await?;
tx.commit().await?;
Ok(Some(updated_job))
}
async fn park_canonical_with_followups(
pool: &PgPool,
job: &JobRow,
progress_snapshot: Option<&serde_json::Value>,
specs: &[crate::enqueue_specs::BoxedEnqueueSpec],
) -> Result<Option<JobRow>, AwaError> {
let mut tx = pool.begin().await?;
let parked_job: Option<JobRow> = sqlx::query_as(
r#"
UPDATE awa.jobs_hot
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
RETURNING *
"#,
)
.bind(job.id)
.bind(job.run_lease)
.bind(progress_snapshot)
.fetch_optional(&mut *tx)
.await?;
let Some(parked_job) = parked_job else {
tx.rollback().await?;
return Ok(None);
};
crate::enqueue_specs::dispatch_specs_in_tx(
&mut tx,
&parked_job,
specs,
Some(&crate::enqueue_specs::OutcomeContext::WaitingForCallback),
)
.await?;
tx.commit().await?;
Ok(Some(parked_job))
}
async fn retry_after_canonical_with_followups(
pool: &PgPool,
job: &JobRow,
seconds: f64,
progress_snapshot: Option<&serde_json::Value>,
specs: &[crate::enqueue_specs::BoxedEnqueueSpec],
) -> Result<Option<JobRow>, AwaError> {
let mut tx = pool.begin().await?;
let updated_job: Option<JobRow> = sqlx::query_as(
r#"
WITH deleted AS (
DELETE FROM awa.jobs_hot
WHERE id = $1 AND state = 'running' AND run_lease = $3
RETURNING *
)
INSERT INTO awa.scheduled_jobs (
id, kind, queue, args, state, priority, attempt, max_attempts,
run_at, heartbeat_at, deadline_at, attempted_at, finalized_at,
created_at, errors, metadata, tags, unique_key, unique_states,
callback_id, callback_timeout_at, callback_filter,
callback_on_complete, callback_on_fail, callback_transform,
run_lease, progress
)
SELECT
id, kind, queue, args,
'retryable'::awa.job_state,
priority, attempt, max_attempts,
now() + make_interval(secs => $2),
heartbeat_at, deadline_at, attempted_at,
now(),
created_at, errors, metadata, tags, unique_key, unique_states,
callback_id, callback_timeout_at, callback_filter,
callback_on_complete, callback_on_fail, callback_transform,
run_lease, $4
FROM deleted
RETURNING *
"#,
)
.bind(job.id)
.bind(seconds)
.bind(job.run_lease)
.bind(progress_snapshot)
.fetch_optional(&mut *tx)
.await?;
let Some(updated_job) = updated_job else {
tx.rollback().await?;
return Ok(None);
};
let outcome_ctx = crate::enqueue_specs::OutcomeContext::Retried {
error: String::new(),
attempt: updated_job.attempt,
next_run_at: updated_job.run_at,
};
crate::enqueue_specs::dispatch_specs_in_tx(&mut tx, &updated_job, specs, Some(&outcome_ctx))
.await?;
tx.commit().await?;
Ok(Some(updated_job))
}
async fn terminal_canonical_with_followups(
pool: &PgPool,
job: &JobRow,
msg: &str,
progress_snapshot: Option<&serde_json::Value>,
specs: &[crate::enqueue_specs::BoxedEnqueueSpec],
) -> Result<Option<JobRow>, AwaError> {
let mut tx = pool.begin().await?;
let updated_job: Option<JobRow> = sqlx::query_as(
r#"
UPDATE awa.jobs_hot
SET state = 'failed',
finalized_at = now(),
errors = errors || $2::jsonb,
progress = $4
WHERE id = $1 AND state = 'running' AND run_lease = $3
RETURNING *
"#,
)
.bind(job.id)
.bind(serde_json::json!({
"error": msg,
"attempt": job.attempt,
"at": chrono::Utc::now().to_rfc3339(),
"terminal": true
}))
.bind(job.run_lease)
.bind(progress_snapshot)
.fetch_optional(&mut *tx)
.await?;
let Some(updated_job) = updated_job else {
tx.rollback().await?;
return Ok(None);
};
let outcome_ctx = crate::enqueue_specs::OutcomeContext::Exhausted {
error: msg.to_string(),
attempt: job.attempt,
};
crate::enqueue_specs::dispatch_specs_in_tx(&mut tx, &updated_job, specs, Some(&outcome_ctx))
.await?;
tx.commit().await?;
Ok(Some(updated_job))
}
async fn exhaust_canonical_with_followups(
pool: &PgPool,
job: &JobRow,
error_msg: &str,
progress_snapshot: Option<&serde_json::Value>,
specs: &[crate::enqueue_specs::BoxedEnqueueSpec],
) -> Result<Option<JobRow>, AwaError> {
let mut tx = pool.begin().await?;
let updated_job: Option<JobRow> = sqlx::query_as(
r#"
UPDATE awa.jobs_hot
SET state = 'failed',
finalized_at = now(),
errors = errors || $2::jsonb,
progress = $4
WHERE id = $1 AND state = 'running' AND run_lease = $3
RETURNING *
"#,
)
.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)
.fetch_optional(&mut *tx)
.await?;
let Some(updated_job) = updated_job else {
tx.rollback().await?;
return Ok(None);
};
let outcome_ctx = crate::enqueue_specs::OutcomeContext::Exhausted {
error: error_msg.to_string(),
attempt: job.attempt,
};
crate::enqueue_specs::dispatch_specs_in_tx(&mut tx, &updated_job, specs, Some(&outcome_ctx))
.await?;
tx.commit().await?;
Ok(Some(updated_job))
}
async fn retry_backoff_canonical_with_followups(
pool: &PgPool,
job: &JobRow,
error_msg: &str,
progress_snapshot: Option<&serde_json::Value>,
specs: &[crate::enqueue_specs::BoxedEnqueueSpec],
) -> Result<Option<JobRow>, AwaError> {
let mut tx = pool.begin().await?;
let updated_job: Option<JobRow> = sqlx::query_as(
r#"
WITH deleted AS (
DELETE FROM awa.jobs_hot
WHERE id = $1 AND state = 'running' AND run_lease = $5
RETURNING *
)
INSERT INTO awa.scheduled_jobs (
id, kind, queue, args, state, priority, attempt, max_attempts,
run_at, heartbeat_at, deadline_at, attempted_at, finalized_at,
created_at, errors, metadata, tags, unique_key, unique_states,
callback_id, callback_timeout_at, callback_filter,
callback_on_complete, callback_on_fail, callback_transform,
run_lease, progress
)
SELECT
id, kind, queue, args,
'retryable'::awa.job_state,
priority, attempt, max_attempts,
now() + awa.backoff_duration($2, $3),
NULL, NULL,
attempted_at,
now(),
created_at,
errors || $4::jsonb,
metadata, tags, unique_key, unique_states,
callback_id, callback_timeout_at, callback_filter,
callback_on_complete, callback_on_fail, callback_transform,
run_lease, $6
FROM deleted
RETURNING *
"#,
)
.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)
.fetch_optional(&mut *tx)
.await?;
let Some(updated_job) = updated_job else {
tx.rollback().await?;
return Ok(None);
};
let outcome_ctx = crate::enqueue_specs::OutcomeContext::Retried {
error: error_msg.to_string(),
attempt: job.attempt,
next_run_at: updated_job.run_at,
};
crate::enqueue_specs::dispatch_specs_in_tx(&mut tx, &updated_job, specs, Some(&outcome_ctx))
.await?;
tx.commit().await?;
Ok(Some(updated_job))
}