Skip to main content

awa_worker/
executor.rs

1use crate::completion::CompletionBatcherHandle;
2use crate::context::{CallbackGuard, JobContext};
3use crate::events::{BoxedUntypedEventHandler, UntypedJobEvent};
4use crate::runtime::{InFlightMap, InFlightState, ProgressState};
5use awa_model::{AwaError, JobRow};
6use sqlx::PgPool;
7use std::any::Any;
8use std::collections::HashMap;
9use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
10use std::sync::Arc;
11use std::time::Duration;
12use tracing::{error, info, info_span, warn, Instrument};
13
14/// Result of executing a job handler.
15///
16/// See also [`JobError`] for the error side — notably [`JobError::Retryable`]
17/// provides error-driven retry with database-computed backoff, while
18/// [`JobResult::RetryAfter`] is an explicit retry with caller-specified delay.
19#[derive(Debug)]
20pub enum JobResult {
21    /// Job completed successfully.
22    Completed,
23    /// Job should be retried after the given duration. Increments attempt.
24    RetryAfter(std::time::Duration),
25    /// Job should be snoozed (re-available after duration). Does NOT increment attempt.
26    Snooze(std::time::Duration),
27    /// Job should be cancelled.
28    Cancel(String),
29    /// Job is waiting for an external callback (webhook completion).
30    ///
31    /// Obtain the required guard from `ctx.register_callback()` or
32    /// `ctx.register_callback_with_config()`.
33    WaitForCallback(CallbackGuard),
34}
35
36/// Error type for job handlers — any error is retryable unless it's terminal.
37///
38/// [`JobError::Retryable`] triggers retry with database-computed exponential backoff.
39/// For explicit caller-controlled retry delay, return [`Ok(JobResult::RetryAfter)`] instead.
40#[derive(Debug, thiserror::Error)]
41pub enum JobError {
42    /// Retryable error — will be retried if attempts remain.
43    #[error("{0}")]
44    Retryable(#[source] Box<dyn std::error::Error + Send + Sync>),
45
46    /// Terminal error — immediately fails the job regardless of remaining attempts.
47    #[error("terminal: {0}")]
48    Terminal(String),
49}
50
51impl JobError {
52    /// Create a retryable error from any `std::error::Error`.
53    pub fn retryable(err: impl std::error::Error + Send + Sync + 'static) -> Self {
54        JobError::Retryable(Box::new(err))
55    }
56
57    /// Create a retryable error from a display message.
58    ///
59    /// Use this with `anyhow::Error` or other types that implement `Display`
60    /// but not `std::error::Error`:
61    /// ```ignore
62    /// Err(JobError::retryable_msg(format!("{err:#}")))
63    /// // or with anyhow:
64    /// Err(JobError::retryable_msg(err))
65    /// ```
66    pub fn retryable_msg(msg: impl std::fmt::Display) -> Self {
67        JobError::Retryable(Box::new(DisplayError(msg.to_string())))
68    }
69
70    /// Create a terminal error — immediately fails the job.
71    pub fn terminal(msg: impl Into<String>) -> Self {
72        JobError::Terminal(msg.into())
73    }
74}
75
76/// Wrapper to turn a Display string into a std::error::Error for retryable_msg.
77#[derive(Debug)]
78struct DisplayError(String);
79
80impl std::fmt::Display for DisplayError {
81    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82        f.write_str(&self.0)
83    }
84}
85
86impl std::error::Error for DisplayError {}
87
88/// With the `anyhow` feature, `?` works directly in handlers:
89/// ```ignore
90/// async fn perform(&self, ctx: &JobContext) -> Result<JobResult, JobError> {
91///     let data = fallible_thing().await?; // anyhow::Error → JobError::Retryable
92///     Ok(JobResult::Completed)
93/// }
94/// ```
95#[cfg(feature = "anyhow")]
96impl From<anyhow::Error> for JobError {
97    fn from(err: anyhow::Error) -> Self {
98        JobError::retryable_msg(format!("{err:#}"))
99    }
100}
101
102/// Worker trait — implement this for each job type.
103///
104/// # Handling permanent failure
105///
106/// When all retry attempts are exhausted, awa moves the job to `failed`.
107/// To run cleanup logic (update external state, send notifications), check
108/// the attempt count inside `perform`:
109///
110/// ```ignore
111/// async fn perform(&self, ctx: &JobContext) -> Result<JobResult, JobError> {
112///     match do_work(ctx).await {
113///         Ok(()) => Ok(JobResult::Completed),
114///         Err(err) if ctx.job.attempt >= ctx.job.max_attempts => {
115///             // Last attempt — run cleanup before awa marks as failed
116///             mark_permanently_failed(ctx.job.id).await;
117///             Err(JobError::retryable(err))
118///         }
119///         Err(err) => Err(JobError::retryable(err)),
120///     }
121/// }
122/// ```
123#[async_trait::async_trait]
124pub trait Worker: Send + Sync + 'static {
125    /// The kind string for this worker (must match the job's kind).
126    fn kind(&self) -> &'static str;
127
128    /// Execute the job. Access the job row via `ctx.job`.
129    async fn perform(&self, ctx: &JobContext) -> Result<JobResult, JobError>;
130}
131
132/// Type-erased worker wrapper for the registry.
133pub(crate) type BoxedWorker = Box<dyn Worker>;
134
135/// Result of a state-transition attempt in `complete_job`.
136#[allow(clippy::large_enum_variant)]
137enum CompletionOutcome {
138    /// The DB update was applied; optionally carries a lifecycle event to dispatch.
139    Applied { event: Option<UntypedJobEvent> },
140    /// The job was already rescued/cancelled — stale completion, no event.
141    IgnoredStale,
142}
143
144/// Manages job execution — spawns worker futures and tracks in-flight jobs.
145pub struct JobExecutor {
146    pool: PgPool,
147    workers: Arc<HashMap<String, BoxedWorker>>,
148    lifecycle_handlers: Arc<HashMap<String, Vec<BoxedUntypedEventHandler>>>,
149    in_flight: InFlightMap,
150    queue_in_flight: Arc<HashMap<String, Arc<AtomicU32>>>,
151    state: Arc<HashMap<std::any::TypeId, Box<dyn Any + Send + Sync>>>,
152    metrics: crate::metrics::AwaMetrics,
153    completion_batcher: CompletionBatcherHandle,
154}
155
156impl JobExecutor {
157    #[allow(clippy::too_many_arguments)]
158    pub(crate) fn new(
159        pool: PgPool,
160        workers: Arc<HashMap<String, BoxedWorker>>,
161        lifecycle_handlers: Arc<HashMap<String, Vec<BoxedUntypedEventHandler>>>,
162        in_flight: InFlightMap,
163        queue_in_flight: Arc<HashMap<String, Arc<AtomicU32>>>,
164        state: Arc<HashMap<std::any::TypeId, Box<dyn Any + Send + Sync>>>,
165        metrics: crate::metrics::AwaMetrics,
166        completion_batcher: CompletionBatcherHandle,
167    ) -> Self {
168        Self {
169            pool,
170            workers,
171            lifecycle_handlers,
172            in_flight,
173            queue_in_flight,
174            state,
175            metrics,
176            completion_batcher,
177        }
178    }
179
180    /// Build the future that executes a claimed job.
181    ///
182    /// The caller is responsible for spawning it onto the runtime.
183    pub fn execute_task(
184        &self,
185        job: JobRow,
186        cancel: Arc<AtomicBool>,
187    ) -> impl std::future::Future<Output = ()> + Send + 'static {
188        let pool = self.pool.clone();
189        let workers = self.workers.clone();
190        let lifecycle_handlers = self.lifecycle_handlers.clone();
191        let in_flight = self.in_flight.clone();
192        let queue_in_flight = self.queue_in_flight.clone();
193        let state = self.state.clone();
194        let metrics = self.metrics.clone();
195        let completion_batcher = self.completion_batcher.clone();
196        let job_id = job.id;
197        let job_run_lease = job.run_lease;
198        let job_kind = job.kind.clone();
199        let job_queue = job.queue.clone();
200
201        let span = info_span!(
202            "job.execute",
203            job.id = job_id,
204            job.kind = %job_kind,
205            job.queue = %job_queue,
206            job.attempt = job.attempt,
207            otel.name = %format!("job.execute {}", job_kind),
208            otel.status_code = tracing::field::Empty,
209        );
210
211        async move {
212            // Seed progress from the persisted checkpoint (for retries/snoozes)
213            let progress_state = Arc::new(std::sync::Mutex::new(ProgressState::new(
214                job.progress.clone(),
215            )));
216
217            // Register as in-flight with cancel + progress
218            let in_flight_state = InFlightState {
219                cancel: cancel.clone(),
220                progress: progress_state.clone(),
221            };
222            in_flight.insert((job_id, job_run_lease), in_flight_state);
223            if let Some(counter) = queue_in_flight.get(&job_queue) {
224                counter.fetch_add(1, Ordering::SeqCst);
225            }
226            metrics.record_in_flight_change(&job_queue, 1);
227
228            let start = std::time::Instant::now();
229            let ctx = JobContext::new(
230                job.clone(),
231                cancel,
232                state,
233                pool.clone(),
234                progress_state.clone(),
235            );
236
237            let result = match workers.get(&job.kind) {
238                Some(worker) => worker.perform(&ctx).await,
239                None => {
240                    error!(kind = %job.kind, job_id, "No worker registered for job kind");
241                    Err(JobError::Terminal(format!(
242                        "unknown job kind: {}",
243                        job.kind
244                    )))
245                }
246            };
247
248            let duration = start.elapsed();
249
250            // Snapshot progress for state transition
251            let progress_snapshot = {
252                let guard = progress_state.lock().expect("progress lock poisoned");
253                guard.clone_latest()
254            };
255
256            // Complete the job based on the result, then record metrics
257            // only if the state transition actually happened (not stale).
258            let has_lifecycle_handlers = lifecycle_handlers.contains_key(&job_kind);
259            let outcome = complete_job(
260                &pool,
261                &job,
262                &result,
263                &completion_batcher,
264                progress_snapshot,
265                duration,
266                has_lifecycle_handlers,
267            )
268            .await;
269
270            match &outcome {
271                Ok(CompletionOutcome::Applied { .. }) => {
272                    // State transition succeeded — record metrics
273                    match &result {
274                        Ok(JobResult::Completed) => {
275                            metrics.record_job_completed(&job_kind, &job_queue, duration);
276                        }
277                        Ok(JobResult::RetryAfter(_)) => {
278                            metrics.record_job_retried(&job_kind, &job_queue);
279                        }
280                        Ok(JobResult::Cancel(_)) => {
281                            metrics.jobs_cancelled.add(
282                                1,
283                                &[
284                                    opentelemetry::KeyValue::new("awa.job.kind", job_kind.clone()),
285                                    opentelemetry::KeyValue::new(
286                                        "awa.job.queue",
287                                        job_queue.clone(),
288                                    ),
289                                ],
290                            );
291                        }
292                        Ok(JobResult::Snooze(_)) => {} // Not a terminal outcome
293                        Ok(JobResult::WaitForCallback(_)) => {
294                            metrics.jobs_waiting_external.add(
295                                1,
296                                &[
297                                    opentelemetry::KeyValue::new("awa.job.kind", job_kind.clone()),
298                                    opentelemetry::KeyValue::new(
299                                        "awa.job.queue",
300                                        job_queue.clone(),
301                                    ),
302                                ],
303                            );
304                        }
305                        Err(JobError::Terminal(_)) => {
306                            metrics.record_job_failed(&job_kind, &job_queue, true);
307                        }
308                        Err(JobError::Retryable(_)) => {
309                            metrics.record_job_retried(&job_kind, &job_queue);
310                        }
311                    }
312                }
313                Ok(CompletionOutcome::IgnoredStale) => {
314                    // Job was already rescued/cancelled — no metrics
315                }
316                Err(err) => {
317                    error!(job_id, error = %err, "Failed to complete job");
318                }
319            }
320
321            // Remove from in-flight BEFORE dispatching lifecycle events.
322            // This ensures a slow/hung handler doesn't hold the permit open,
323            // block queue capacity, or delay graceful shutdown.
324            in_flight.remove((job_id, job_run_lease));
325            if let Some(counter) = queue_in_flight.get(&job_queue) {
326                counter.fetch_sub(1, Ordering::SeqCst);
327            }
328            metrics.record_in_flight_change(&job_queue, -1);
329
330            // Dispatch lifecycle event as a detached task — best effort,
331            // does not block the executor or affect shutdown drain.
332            if let Ok(CompletionOutcome::Applied {
333                event: Some(event), ..
334            }) = outcome
335            {
336                let handlers = lifecycle_handlers.clone();
337                let kind = job_kind.clone();
338                tokio::spawn(async move {
339                    dispatch_lifecycle_event(&handlers, &kind, event).await;
340                });
341            }
342        }
343        .instrument(span)
344    }
345}
346
347/// Update job state in the database based on handler result.
348///
349/// Returns a `CompletionOutcome` indicating whether the state transition was
350/// applied (with an optional lifecycle event) or ignored as stale.
351async fn complete_job(
352    pool: &PgPool,
353    job: &JobRow,
354    result: &Result<JobResult, JobError>,
355    completion_batcher: &CompletionBatcherHandle,
356    progress_snapshot: Option<serde_json::Value>,
357    duration: Duration,
358    needs_event: bool,
359) -> Result<CompletionOutcome, AwaError> {
360    match result {
361        Ok(JobResult::Completed) => {
362            tracing::Span::current().record("otel.status_code", "OK");
363            info!(job_id = job.id, kind = %job.kind, attempt = job.attempt, "Job completed");
364            let result = match completion_batcher.complete(job.id, job.run_lease).await {
365                Ok(updated) => updated,
366                Err(err) => {
367                    warn!(
368                        job_id = job.id,
369                        error = %err,
370                        "Completion batch flush failed, falling back to direct finalize"
371                    );
372                    direct_complete_job(pool, job).await?
373                }
374            };
375            if !result {
376                warn!(
377                    job_id = job.id,
378                    "Job already rescued/cancelled, completion ignored"
379                );
380                return Ok(CompletionOutcome::IgnoredStale);
381            }
382            if needs_event {
383                let updated_job: JobRow = sqlx::query_as("SELECT * FROM awa.jobs WHERE id = $1")
384                    .bind(job.id)
385                    .fetch_one(pool)
386                    .await?;
387                Ok(CompletionOutcome::Applied {
388                    event: Some(UntypedJobEvent::Completed {
389                        job: updated_job,
390                        duration,
391                    }),
392                })
393            } else {
394                Ok(CompletionOutcome::Applied { event: None })
395            }
396        }
397
398        Ok(JobResult::RetryAfter(retry_duration)) => {
399            let seconds = retry_duration.as_secs() as f64;
400            info!(
401                job_id = job.id,
402                kind = %job.kind,
403                retry_after_secs = seconds,
404                "Job requested retry after duration"
405            );
406            let result = sqlx::query(
407                r#"
408                UPDATE awa.jobs
409                SET state = 'retryable',
410                    run_at = now() + make_interval(secs => $2),
411                    finalized_at = now(),
412                    progress = $4
413                WHERE id = $1 AND state = 'running' AND run_lease = $3
414                "#,
415            )
416            .bind(job.id)
417            .bind(seconds)
418            .bind(job.run_lease)
419            .bind(&progress_snapshot)
420            .execute(pool)
421            .await?;
422            if result.rows_affected() == 0 {
423                warn!(
424                    job_id = job.id,
425                    "Job already rescued/cancelled, retry ignored"
426                );
427                return Ok(CompletionOutcome::IgnoredStale);
428            }
429            if needs_event {
430                let updated_job: JobRow = sqlx::query_as("SELECT * FROM awa.jobs WHERE id = $1")
431                    .bind(job.id)
432                    .fetch_one(pool)
433                    .await?;
434                Ok(CompletionOutcome::Applied {
435                    event: Some(UntypedJobEvent::Retried {
436                        job: updated_job.clone(),
437                        error: String::new(),
438                        attempt: updated_job.attempt,
439                        next_run_at: updated_job.run_at,
440                    }),
441                })
442            } else {
443                Ok(CompletionOutcome::Applied { event: None })
444            }
445        }
446
447        Ok(JobResult::Snooze(snooze_duration)) => {
448            let seconds = snooze_duration.as_secs() as f64;
449            info!(
450                job_id = job.id,
451                kind = %job.kind,
452                snooze_secs = seconds,
453                "Job snoozed (attempt not incremented)"
454            );
455            // Snooze: back to available with new run_at, decrement attempt
456            // (since it was already incremented at claim time)
457            let result = sqlx::query(
458                r#"
459                UPDATE awa.jobs
460                SET state = 'scheduled',
461                    run_at = now() + make_interval(secs => $2),
462                    attempt = attempt - 1,
463                    heartbeat_at = NULL,
464                    deadline_at = NULL,
465                    progress = $4
466                WHERE id = $1 AND state = 'running' AND run_lease = $3
467                "#,
468            )
469            .bind(job.id)
470            .bind(seconds)
471            .bind(job.run_lease)
472            .bind(&progress_snapshot)
473            .execute(pool)
474            .await?;
475            if result.rows_affected() == 0 {
476                warn!(
477                    job_id = job.id,
478                    "Job already rescued/cancelled, snooze ignored"
479                );
480                return Ok(CompletionOutcome::IgnoredStale);
481            }
482            // Snooze is not a terminal event — no lifecycle event
483            Ok(CompletionOutcome::Applied { event: None })
484        }
485
486        Ok(JobResult::Cancel(reason)) => {
487            info!(
488                job_id = job.id,
489                kind = %job.kind,
490                reason = %reason,
491                "Job cancelled by handler"
492            );
493            let result = sqlx::query(
494                r#"
495                UPDATE awa.jobs
496                SET state = 'cancelled',
497                    finalized_at = now(),
498                    errors = errors || $2::jsonb,
499                    progress = $4
500                WHERE id = $1 AND state = 'running' AND run_lease = $3
501                "#,
502            )
503            .bind(job.id)
504            .bind(serde_json::json!({
505                "error": format!("cancelled: {}", reason),
506                "attempt": job.attempt,
507                "at": chrono::Utc::now().to_rfc3339()
508            }))
509            .bind(job.run_lease)
510            .bind(&progress_snapshot)
511            .execute(pool)
512            .await?;
513            if result.rows_affected() == 0 {
514                warn!(
515                    job_id = job.id,
516                    "Job already rescued/cancelled, cancel ignored"
517                );
518                return Ok(CompletionOutcome::IgnoredStale);
519            }
520            if needs_event {
521                let updated_job: JobRow = sqlx::query_as("SELECT * FROM awa.jobs WHERE id = $1")
522                    .bind(job.id)
523                    .fetch_one(pool)
524                    .await?;
525                Ok(CompletionOutcome::Applied {
526                    event: Some(UntypedJobEvent::Cancelled {
527                        job: updated_job,
528                        reason: reason.clone(),
529                    }),
530                })
531            } else {
532                Ok(CompletionOutcome::Applied { event: None })
533            }
534        }
535
536        Ok(JobResult::WaitForCallback(_guard)) => {
537            info!(
538                job_id = job.id,
539                kind = %job.kind,
540                "Job waiting for external callback"
541            );
542            // Transition to waiting_external. Requires callback_id to be set
543            // (handler must have called register_callback).
544            let result = sqlx::query(
545                r#"
546                UPDATE awa.jobs
547                SET state = 'waiting_external',
548                    heartbeat_at = NULL,
549                    deadline_at = NULL,
550                    progress = $3
551                WHERE id = $1 AND state = 'running' AND run_lease = $2 AND callback_id IS NOT NULL
552                "#,
553            )
554            .bind(job.id)
555            .bind(job.run_lease)
556            .bind(&progress_snapshot)
557            .execute(pool)
558            .await?;
559            if result.rows_affected() == 0 {
560                // Check if a racing callback already completed/failed the job,
561                // or if the handler forgot to call register_callback.
562                let current: Option<(awa_model::JobState, Option<uuid::Uuid>)> =
563                    sqlx::query_as("SELECT state, callback_id FROM awa.jobs WHERE id = $1")
564                        .bind(job.id)
565                        .fetch_optional(pool)
566                        .await?;
567                match current {
568                    Some((state, _)) if state.is_terminal() => {
569                        // Racing callback already completed the job — all good
570                        info!(
571                            job_id = job.id,
572                            state = %state,
573                            "Job already completed by racing callback"
574                        );
575                        // No lifecycle event for wait-for-callback
576                        return Ok(CompletionOutcome::Applied { event: None });
577                    }
578                    Some((_, None)) => {
579                        // Still running but no callback_id — programming error
580                        error!(
581                            job_id = job.id,
582                            "WaitForCallback returned without calling register_callback"
583                        );
584                        sqlx::query(
585                            r#"
586                            UPDATE awa.jobs
587                            SET state = 'failed',
588                                finalized_at = now(),
589                                errors = errors || $2::jsonb
590                            WHERE id = $1 AND state = 'running' AND run_lease = $3
591                            "#,
592                        )
593                        .bind(job.id)
594                        .bind(serde_json::json!({
595                            "error": "WaitForCallback returned without calling register_callback",
596                            "attempt": job.attempt,
597                            "at": chrono::Utc::now().to_rfc3339(),
598                            "terminal": true
599                        }))
600                        .bind(job.run_lease)
601                        .execute(pool)
602                        .await?;
603                        // No lifecycle event for wait-for-callback
604                        return Ok(CompletionOutcome::Applied { event: None });
605                    }
606                    _ => {
607                        warn!(
608                            job_id = job.id,
609                            "Job already rescued/cancelled, wait-for-callback ignored"
610                        );
611                        return Ok(CompletionOutcome::IgnoredStale);
612                    }
613                }
614            }
615            // No lifecycle event for wait-for-callback
616            Ok(CompletionOutcome::Applied { event: None })
617        }
618
619        Err(JobError::Terminal(msg)) => {
620            tracing::Span::current().record("otel.status_code", "ERROR");
621            error!(
622                job_id = job.id,
623                kind = %job.kind,
624                error = %msg,
625                "Job failed terminally"
626            );
627            let result = sqlx::query(
628                r#"
629                UPDATE awa.jobs
630                SET state = 'failed',
631                    finalized_at = now(),
632                    errors = errors || $2::jsonb,
633                    progress = $4
634                WHERE id = $1 AND state = 'running' AND run_lease = $3
635                "#,
636            )
637            .bind(job.id)
638            .bind(serde_json::json!({
639                "error": msg.to_string(),
640                "attempt": job.attempt,
641                "at": chrono::Utc::now().to_rfc3339(),
642                "terminal": true
643            }))
644            .bind(job.run_lease)
645            .bind(&progress_snapshot)
646            .execute(pool)
647            .await?;
648            if result.rows_affected() == 0 {
649                warn!(
650                    job_id = job.id,
651                    "Job already rescued/cancelled, terminal failure ignored"
652                );
653                return Ok(CompletionOutcome::IgnoredStale);
654            }
655            if needs_event {
656                let updated_job: JobRow = sqlx::query_as("SELECT * FROM awa.jobs WHERE id = $1")
657                    .bind(job.id)
658                    .fetch_one(pool)
659                    .await?;
660                Ok(CompletionOutcome::Applied {
661                    event: Some(UntypedJobEvent::Exhausted {
662                        job: updated_job,
663                        error: msg.clone(),
664                        attempt: job.attempt,
665                    }),
666                })
667            } else {
668                Ok(CompletionOutcome::Applied { event: None })
669            }
670        }
671
672        Err(JobError::Retryable(err)) => {
673            let error_msg = err.to_string();
674            if job.attempt >= job.max_attempts {
675                tracing::Span::current().record("otel.status_code", "ERROR");
676                error!(
677                    job_id = job.id,
678                    kind = %job.kind,
679                    attempt = job.attempt,
680                    max_attempts = job.max_attempts,
681                    error = %error_msg,
682                    "Job failed (max attempts exhausted)"
683                );
684                let result = sqlx::query(
685                    r#"
686                    UPDATE awa.jobs
687                    SET state = 'failed',
688                        finalized_at = now(),
689                        errors = errors || $2::jsonb,
690                        progress = $4
691                    WHERE id = $1 AND state = 'running' AND run_lease = $3
692                    "#,
693                )
694                .bind(job.id)
695                .bind(serde_json::json!({
696                    "error": error_msg,
697                    "attempt": job.attempt,
698                    "at": chrono::Utc::now().to_rfc3339()
699                }))
700                .bind(job.run_lease)
701                .bind(&progress_snapshot)
702                .execute(pool)
703                .await?;
704                if result.rows_affected() == 0 {
705                    warn!(
706                        job_id = job.id,
707                        "Job already rescued/cancelled, failure ignored"
708                    );
709                    return Ok(CompletionOutcome::IgnoredStale);
710                }
711                if needs_event {
712                    let updated_job: JobRow =
713                        sqlx::query_as("SELECT * FROM awa.jobs WHERE id = $1")
714                            .bind(job.id)
715                            .fetch_one(pool)
716                            .await?;
717                    Ok(CompletionOutcome::Applied {
718                        event: Some(UntypedJobEvent::Exhausted {
719                            job: updated_job,
720                            error: error_msg,
721                            attempt: job.attempt,
722                        }),
723                    })
724                } else {
725                    Ok(CompletionOutcome::Applied { event: None })
726                }
727            } else {
728                warn!(
729                    job_id = job.id,
730                    kind = %job.kind,
731                    attempt = job.attempt,
732                    error = %error_msg,
733                    "Job failed (will retry)"
734                );
735                // Use database-side backoff calculation
736                let result = sqlx::query(
737                    r#"
738                    UPDATE awa.jobs
739                    SET state = 'retryable',
740                        run_at = now() + awa.backoff_duration($2, $3),
741                        finalized_at = now(),
742                        heartbeat_at = NULL,
743                        deadline_at = NULL,
744                        errors = errors || $4::jsonb,
745                        progress = $6
746                    WHERE id = $1 AND state = 'running' AND run_lease = $5
747                    "#,
748                )
749                .bind(job.id)
750                .bind(job.attempt)
751                .bind(job.max_attempts)
752                .bind(serde_json::json!({
753                    "error": error_msg,
754                    "attempt": job.attempt,
755                    "at": chrono::Utc::now().to_rfc3339()
756                }))
757                .bind(job.run_lease)
758                .bind(&progress_snapshot)
759                .execute(pool)
760                .await?;
761                if result.rows_affected() == 0 {
762                    warn!(
763                        job_id = job.id,
764                        "Job already rescued/cancelled, retry ignored"
765                    );
766                    return Ok(CompletionOutcome::IgnoredStale);
767                }
768                if needs_event {
769                    let updated_job: JobRow =
770                        sqlx::query_as("SELECT * FROM awa.jobs WHERE id = $1")
771                            .bind(job.id)
772                            .fetch_one(pool)
773                            .await?;
774                    Ok(CompletionOutcome::Applied {
775                        event: Some(UntypedJobEvent::Retried {
776                            job: updated_job.clone(),
777                            error: error_msg,
778                            attempt: job.attempt,
779                            next_run_at: updated_job.run_at,
780                        }),
781                    })
782                } else {
783                    Ok(CompletionOutcome::Applied { event: None })
784                }
785            }
786        }
787    }
788}
789
790/// Dispatch a lifecycle event to all registered handlers for a job kind.
791///
792/// Handlers are called sequentially. Panics are caught and logged — a
793/// misbehaving handler cannot crash the dispatch loop or lose events
794/// for subsequent handlers.
795async fn dispatch_lifecycle_event(
796    handlers: &HashMap<String, Vec<BoxedUntypedEventHandler>>,
797    kind: &str,
798    event: UntypedJobEvent,
799) {
800    if let Some(handlers) = handlers.get(kind) {
801        for handler in handlers {
802            let handler = handler.clone();
803            let event = event.clone();
804            let result = tokio::spawn(async move {
805                (handler)(event).await;
806            })
807            .await;
808            if let Err(err) = result {
809                tracing::warn!(
810                    kind,
811                    error = %err,
812                    "Lifecycle event handler panicked"
813                );
814            }
815        }
816    }
817}
818
819async fn direct_complete_job(pool: &PgPool, job: &JobRow) -> Result<bool, AwaError> {
820    let result = sqlx::query(
821        r#"
822        UPDATE awa.jobs_hot
823        SET state = 'completed',
824            finalized_at = now(),
825            progress = NULL
826        WHERE id = $1 AND state = 'running' AND run_lease = $2
827        "#,
828    )
829    .bind(job.id)
830    .bind(job.run_lease)
831    .execute(pool)
832    .await?;
833
834    Ok(result.rows_affected() > 0)
835}