1use crate::completion::CompletionBatcherHandle;
2use crate::context::{CallbackGuard, JobContext};
3use crate::events::{BoxedUntypedEventHandler, UntypedJobEvent};
4use crate::runtime::{InFlightMap, InFlightState, ProgressState};
5use crate::storage::{QueueStorageRuntime, RuntimeStorage};
6use awa_model::{AwaError, ClaimedEntry, ClaimedRuntimeJob, JobRow, JobState};
7use sqlx::PgPool;
8use std::any::Any;
9use std::collections::HashMap;
10use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
11use std::sync::Arc;
12use std::time::Duration;
13use tracing::{error, info, info_span, warn, Instrument};
14
15#[derive(Debug)]
40pub enum JobResult {
41 Completed,
43 RetryAfter(std::time::Duration),
48 Snooze(std::time::Duration),
53 Cancel(String),
58 WaitForCallback(CallbackGuard),
63}
64
65#[derive(Debug, thiserror::Error)]
70pub enum JobError {
71 #[error("{0}")]
73 Retryable(#[source] Box<dyn std::error::Error + Send + Sync>),
74
75 #[error("terminal: {0}")]
77 Terminal(String),
78}
79
80impl JobError {
81 pub fn retryable(err: impl std::error::Error + Send + Sync + 'static) -> Self {
83 JobError::Retryable(Box::new(err))
84 }
85
86 pub fn retryable_msg(msg: impl std::fmt::Display) -> Self {
96 JobError::Retryable(Box::new(DisplayError(msg.to_string())))
97 }
98
99 pub fn terminal(msg: impl Into<String>) -> Self {
101 JobError::Terminal(msg.into())
102 }
103}
104
105#[derive(Debug, Clone, Default)]
107pub struct DlqPolicy {
108 pub enabled_default: bool,
109 pub overrides: Arc<HashMap<String, bool>>,
110}
111
112impl DlqPolicy {
113 pub fn new(enabled_default: bool, overrides: HashMap<String, bool>) -> Self {
114 Self {
115 enabled_default,
116 overrides: Arc::new(overrides),
117 }
118 }
119
120 pub fn enabled_for(&self, queue: &str) -> bool {
121 self.overrides
122 .get(queue)
123 .copied()
124 .unwrap_or(self.enabled_default)
125 }
126}
127
128#[derive(Debug)]
130struct DisplayError(String);
131
132impl std::fmt::Display for DisplayError {
133 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134 f.write_str(&self.0)
135 }
136}
137
138impl std::error::Error for DisplayError {}
139
140#[cfg(feature = "anyhow")]
148impl From<anyhow::Error> for JobError {
149 fn from(err: anyhow::Error) -> Self {
150 JobError::retryable_msg(format!("{err:#}"))
151 }
152}
153
154#[async_trait::async_trait]
176pub trait Worker: Send + Sync + 'static {
177 fn kind(&self) -> &'static str;
179
180 async fn perform(&self, ctx: &JobContext) -> Result<JobResult, JobError>;
182}
183
184pub(crate) type BoxedWorker = Box<dyn Worker>;
186
187#[allow(clippy::large_enum_variant)]
189enum CompletionOutcome {
190 Applied {
192 event: Option<UntypedJobEvent>,
193 terminal: bool,
194 },
195 IgnoredStale,
197}
198
199#[derive(Debug, Clone)]
200pub(crate) struct DispatchedJob {
201 pub job: JobRow,
202 pub queue_storage_claim: Option<ClaimedEntry>,
203 pub queue_storage_unique_states: Option<String>,
204}
205
206pub struct JobExecutor {
208 pool: PgPool,
209 workers: Arc<HashMap<String, BoxedWorker>>,
210 lifecycle_handlers: Arc<HashMap<String, Vec<BoxedUntypedEventHandler>>>,
211 enqueue_specs: Arc<
212 HashMap<
213 crate::enqueue_specs::Outcome,
214 HashMap<String, Vec<crate::enqueue_specs::BoxedEnqueueSpec>>,
215 >,
216 >,
217 in_flight: InFlightMap,
218 queue_in_flight: Arc<HashMap<String, Arc<AtomicU32>>>,
219 state: Arc<HashMap<std::any::TypeId, Box<dyn Any + Send + Sync>>>,
220 metrics: crate::metrics::AwaMetrics,
221 completion_batcher: CompletionBatcherHandle,
222 storage: RuntimeStorage,
223 dlq_policy: DlqPolicy,
224}
225
226impl JobExecutor {
227 #[allow(clippy::too_many_arguments)]
228 pub(crate) fn new(
229 pool: PgPool,
230 workers: Arc<HashMap<String, BoxedWorker>>,
231 lifecycle_handlers: Arc<HashMap<String, Vec<BoxedUntypedEventHandler>>>,
232 enqueue_specs: Arc<
233 HashMap<
234 crate::enqueue_specs::Outcome,
235 HashMap<String, Vec<crate::enqueue_specs::BoxedEnqueueSpec>>,
236 >,
237 >,
238 in_flight: InFlightMap,
239 queue_in_flight: Arc<HashMap<String, Arc<AtomicU32>>>,
240 state: Arc<HashMap<std::any::TypeId, Box<dyn Any + Send + Sync>>>,
241 metrics: crate::metrics::AwaMetrics,
242 completion_batcher: CompletionBatcherHandle,
243 storage: RuntimeStorage,
244 dlq_policy: DlqPolicy,
245 ) -> Self {
246 Self {
247 pool,
248 workers,
249 lifecycle_handlers,
250 enqueue_specs,
251 in_flight,
252 queue_in_flight,
253 state,
254 metrics,
255 completion_batcher,
256 storage,
257 dlq_policy,
258 }
259 }
260
261 pub(crate) fn execute_task(
265 &self,
266 dispatched: DispatchedJob,
267 cancel: Arc<AtomicBool>,
268 ) -> impl std::future::Future<Output = ()> + Send + 'static {
269 let job = dispatched.job;
270 let queue_storage_claim = dispatched.queue_storage_claim;
271 let queue_storage_unique_states = dispatched.queue_storage_unique_states;
272 let pool = self.pool.clone();
273 let workers = self.workers.clone();
274 let lifecycle_handlers = self.lifecycle_handlers.clone();
275 let enqueue_specs = self.enqueue_specs.clone();
276 let in_flight = self.in_flight.clone();
277 let queue_in_flight = self.queue_in_flight.clone();
278 let state = self.state.clone();
279 let metrics = self.metrics.clone();
280 let completion_batcher = self.completion_batcher.clone();
281 let storage = self.storage.clone();
282 let dlq_policy = self.dlq_policy.clone();
283 let job_id = job.id;
284 let job_run_lease = job.run_lease;
285 let job_kind = job.kind.clone();
286 let job_queue = job.queue.clone();
287
288 let span = info_span!(
289 "job.execute",
290 job.id = job_id,
291 job.kind = %job_kind,
292 job.queue = %job_queue,
293 job.attempt = job.attempt,
294 otel.name = %format!("job.execute {}", job_kind),
295 otel.status_code = tracing::field::Empty,
296 );
297
298 async move {
299 let progress_state = Arc::new(std::sync::Mutex::new(ProgressState::new(
301 job.progress.clone(),
302 )));
303
304 let in_flight_state = InFlightState {
306 cancel: cancel.clone(),
307 progress: progress_state.clone(),
308 };
309 in_flight.insert((job_id, job_run_lease), in_flight_state);
310 if let Some(counter) = queue_in_flight.get(&job_queue) {
311 counter.fetch_add(1, Ordering::SeqCst);
312 }
313 metrics.record_in_flight_change(&job_queue, 1);
314 let has_lifecycle_handlers = lifecycle_handlers.contains_key(&job_kind);
315
316 let start = std::time::Instant::now();
317 let ctx = JobContext::new(
318 job.clone(),
319 cancel,
320 state,
321 pool.clone(),
322 storage.clone(),
323 progress_state.clone(),
324 );
325
326 let result = match workers.get(&job.kind) {
327 Some(worker) => {
328 if has_lifecycle_handlers {
329 let started_handlers = lifecycle_handlers.clone();
330 let started_kind = job_kind.clone();
331 let started_job = job.clone();
332 tokio::spawn(async move {
333 dispatch_lifecycle_event(
334 &started_handlers,
335 &started_kind,
336 UntypedJobEvent::Started { job: started_job },
337 )
338 .await;
339 });
340 }
341 worker.perform(&ctx).await
342 }
343 None => {
344 error!(kind = %job.kind, job_id, "No worker registered for job kind");
345 Err(JobError::Terminal(format!(
346 "unknown job kind: {}",
347 job.kind
348 )))
349 }
350 };
351
352 let duration = start.elapsed();
353
354 let progress_snapshot = {
356 let guard = progress_state.lock().expect("progress lock poisoned");
357 guard.clone_latest()
358 };
359
360 in_flight.remove((job_id, job_run_lease));
365 if let Some(counter) = queue_in_flight.get(&job_queue) {
366 counter.fetch_sub(1, Ordering::SeqCst);
367 }
368 metrics.record_in_flight_change(&job_queue, -1);
369
370 let dlq_enabled = dlq_policy.enabled_for(&job_queue);
371 tokio::spawn(async move {
372 let outcome = complete_job(
373 &pool,
374 &job,
375 queue_storage_claim.as_ref(),
376 queue_storage_unique_states.as_deref(),
377 &result,
378 &completion_batcher,
379 progress_snapshot,
380 duration,
381 has_lifecycle_handlers,
382 &enqueue_specs,
383 &storage,
384 dlq_enabled,
385 &metrics,
386 )
387 .await;
388
389 match &outcome {
390 Ok(CompletionOutcome::Applied { terminal, .. }) => {
391 match &result {
395 Ok(JobResult::Completed) => {
396 metrics.record_job_completed(&job_kind, &job_queue, duration);
397 }
398 Ok(JobResult::RetryAfter(_)) => {
399 metrics.record_job_retried(&job_kind, &job_queue);
400 }
401 Ok(JobResult::Cancel(_)) => {
402 metrics.jobs_cancelled.add(
403 1,
404 &[
405 opentelemetry::KeyValue::new(
406 "awa.job.kind",
407 job_kind.clone(),
408 ),
409 opentelemetry::KeyValue::new(
410 "awa.job.queue",
411 job_queue.clone(),
412 ),
413 ],
414 );
415 }
416 Ok(JobResult::Snooze(_)) => {}
417 Ok(JobResult::WaitForCallback(_)) => {
418 if *terminal {
419 metrics.record_job_failed(&job_kind, &job_queue, true);
420 } else {
421 metrics.jobs_waiting_external.add(
422 1,
423 &[
424 opentelemetry::KeyValue::new(
425 "awa.job.kind",
426 job_kind.clone(),
427 ),
428 opentelemetry::KeyValue::new(
429 "awa.job.queue",
430 job_queue.clone(),
431 ),
432 ],
433 );
434 }
435 }
436 Err(JobError::Terminal(_)) => {
437 metrics.record_job_failed(&job_kind, &job_queue, true);
438 }
439 Err(JobError::Retryable(_)) => {
440 if *terminal {
441 metrics.record_job_failed(&job_kind, &job_queue, true);
442 } else {
443 metrics.record_job_retried(&job_kind, &job_queue);
444 }
445 }
446 }
447 }
448 Ok(CompletionOutcome::IgnoredStale) => {}
449 Err(err) => {
450 error!(job_id, error = %err, "Failed to complete job");
451 }
452 }
453
454 if let Ok(CompletionOutcome::Applied {
455 event: Some(event), ..
456 }) = outcome
457 {
458 dispatch_lifecycle_event(&lifecycle_handlers, &job_kind, event).await;
459 }
460 });
461 }
462 .instrument(span)
463 }
464}
465
466#[allow(clippy::too_many_arguments)]
471async fn complete_job(
472 pool: &PgPool,
473 job: &JobRow,
474 queue_storage_claim: Option<&ClaimedEntry>,
475 queue_storage_unique_states: Option<&str>,
476 result: &Result<JobResult, JobError>,
477 completion_batcher: &CompletionBatcherHandle,
478 progress_snapshot: Option<serde_json::Value>,
479 duration: Duration,
480 needs_event: bool,
481 enqueue_specs: &Arc<
482 HashMap<
483 crate::enqueue_specs::Outcome,
484 HashMap<String, Vec<crate::enqueue_specs::BoxedEnqueueSpec>>,
485 >,
486 >,
487 storage: &RuntimeStorage,
488 dlq_enabled: bool,
489 metrics: &crate::metrics::AwaMetrics,
490) -> Result<CompletionOutcome, AwaError> {
491 match storage {
492 RuntimeStorage::Canonical => {
493 complete_job_canonical(
494 pool,
495 job,
496 result,
497 completion_batcher,
498 progress_snapshot,
499 duration,
500 needs_event,
501 enqueue_specs,
502 dlq_enabled,
503 metrics,
504 )
505 .await
506 }
507 RuntimeStorage::QueueStorage(runtime) => {
508 complete_job_queue_storage(
509 runtime,
510 pool,
511 job,
512 queue_storage_claim,
513 queue_storage_unique_states,
514 result,
515 completion_batcher,
516 progress_snapshot,
517 duration,
518 needs_event,
519 enqueue_specs,
520 dlq_enabled,
521 metrics,
522 )
523 .await
524 }
525 }
526}
527
528#[allow(clippy::too_many_arguments)]
529async fn complete_job_canonical(
530 pool: &PgPool,
531 job: &JobRow,
532 result: &Result<JobResult, JobError>,
533 completion_batcher: &CompletionBatcherHandle,
534 progress_snapshot: Option<serde_json::Value>,
535 duration: Duration,
536 needs_event: bool,
537 enqueue_specs: &Arc<
538 HashMap<
539 crate::enqueue_specs::Outcome,
540 HashMap<String, Vec<crate::enqueue_specs::BoxedEnqueueSpec>>,
541 >,
542 >,
543 _dlq_enabled: bool,
544 _metrics: &crate::metrics::AwaMetrics,
545) -> Result<CompletionOutcome, AwaError> {
546 match result {
547 Ok(JobResult::Completed) => {
548 tracing::Span::current().record("otel.status_code", "OK");
549 info!(job_id = job.id, kind = %job.kind, attempt = job.attempt, "Job completed");
550
551 let kind_specs = enqueue_specs
556 .get(&crate::enqueue_specs::Outcome::Completed)
557 .and_then(|by_kind| by_kind.get(&job.kind))
558 .cloned();
559 if let Some(specs) = kind_specs.filter(|s| !s.is_empty()) {
560 let outcome = complete_canonical_with_followups(pool, job, &specs).await?;
561 return match outcome {
562 None => {
563 warn!(
564 job_id = job.id,
565 "Job already rescued/cancelled, completion ignored"
566 );
567 Ok(CompletionOutcome::IgnoredStale)
568 }
569 Some(updated_job) => {
570 let event = if needs_event {
571 Some(UntypedJobEvent::Completed {
572 job: updated_job,
573 duration,
574 })
575 } else {
576 None
577 };
578 Ok(CompletionOutcome::Applied {
579 event,
580 terminal: false,
581 })
582 }
583 };
584 }
585
586 let result = match completion_batcher.complete(job.id, job.run_lease).await {
587 Ok(updated) => updated,
588 Err(err) => {
589 warn!(
590 job_id = job.id,
591 error = %err,
592 "Completion batch flush failed, falling back to direct finalize"
593 );
594 direct_complete_job(pool, job).await?
595 }
596 };
597 if !result {
598 warn!(
599 job_id = job.id,
600 "Job already rescued/cancelled, completion ignored"
601 );
602 return Ok(CompletionOutcome::IgnoredStale);
603 }
604 if needs_event {
605 let updated_job: JobRow = sqlx::query_as("SELECT * FROM awa.jobs WHERE id = $1")
606 .bind(job.id)
607 .fetch_one(pool)
608 .await?;
609 Ok(CompletionOutcome::Applied {
610 event: Some(UntypedJobEvent::Completed {
611 job: updated_job,
612 duration,
613 }),
614 terminal: false,
615 })
616 } else {
617 Ok(CompletionOutcome::Applied {
618 event: None,
619 terminal: false,
620 })
621 }
622 }
623
624 Ok(JobResult::RetryAfter(retry_duration)) => {
625 let seconds = retry_duration.as_secs() as f64;
626 info!(
627 job_id = job.id,
628 kind = %job.kind,
629 retry_after_secs = seconds,
630 "Job requested retry after duration"
631 );
632
633 let kind_specs = enqueue_specs
638 .get(&crate::enqueue_specs::Outcome::Retried)
639 .and_then(|by_kind| by_kind.get(&job.kind))
640 .cloned();
641 if let Some(specs) = kind_specs.filter(|s| !s.is_empty()) {
642 let result = retry_after_canonical_with_followups(
643 pool,
644 job,
645 seconds,
646 progress_snapshot.as_ref(),
647 &specs,
648 )
649 .await?;
650 return match result {
651 None => {
652 warn!(
653 job_id = job.id,
654 "Job already rescued/cancelled, retry ignored"
655 );
656 Ok(CompletionOutcome::IgnoredStale)
657 }
658 Some(updated_job) => {
659 let event = if needs_event {
660 Some(UntypedJobEvent::Retried {
661 job: updated_job.clone(),
662 error: String::new(),
663 attempt: updated_job.attempt,
664 next_run_at: updated_job.run_at,
665 })
666 } else {
667 None
668 };
669 Ok(CompletionOutcome::Applied {
670 event,
671 terminal: false,
672 })
673 }
674 };
675 }
676
677 let result = sqlx::query(
678 r#"
679 UPDATE awa.jobs
680 SET state = 'retryable',
681 run_at = now() + make_interval(secs => $2),
682 finalized_at = now(),
683 progress = $4
684 WHERE id = $1 AND state = 'running' AND run_lease = $3
685 "#,
686 )
687 .bind(job.id)
688 .bind(seconds)
689 .bind(job.run_lease)
690 .bind(&progress_snapshot)
691 .execute(pool)
692 .await?;
693 if result.rows_affected() == 0 {
694 warn!(
695 job_id = job.id,
696 "Job already rescued/cancelled, retry ignored"
697 );
698 return Ok(CompletionOutcome::IgnoredStale);
699 }
700 if needs_event {
701 let updated_job: JobRow = sqlx::query_as("SELECT * FROM awa.jobs WHERE id = $1")
702 .bind(job.id)
703 .fetch_one(pool)
704 .await?;
705 Ok(CompletionOutcome::Applied {
706 event: Some(UntypedJobEvent::Retried {
707 job: updated_job.clone(),
708 error: String::new(),
709 attempt: updated_job.attempt,
710 next_run_at: updated_job.run_at,
711 }),
712 terminal: false,
713 })
714 } else {
715 Ok(CompletionOutcome::Applied {
716 event: None,
717 terminal: false,
718 })
719 }
720 }
721
722 Ok(JobResult::Snooze(snooze_duration)) => {
723 let seconds = snooze_duration.as_secs() as f64;
724 info!(
725 job_id = job.id,
726 kind = %job.kind,
727 snooze_secs = seconds,
728 "Job snoozed (attempt not incremented)"
729 );
730 let result = sqlx::query(
731 r#"
732 UPDATE awa.jobs
733 SET state = 'scheduled',
734 run_at = now() + make_interval(secs => $2),
735 attempt = attempt - 1,
736 heartbeat_at = NULL,
737 deadline_at = NULL,
738 progress = $4
739 WHERE id = $1 AND state = 'running' AND run_lease = $3
740 "#,
741 )
742 .bind(job.id)
743 .bind(seconds)
744 .bind(job.run_lease)
745 .bind(&progress_snapshot)
746 .execute(pool)
747 .await?;
748 if result.rows_affected() == 0 {
749 warn!(
750 job_id = job.id,
751 "Job already rescued/cancelled, snooze ignored"
752 );
753 return Ok(CompletionOutcome::IgnoredStale);
754 }
755 Ok(CompletionOutcome::Applied {
756 event: None,
757 terminal: false,
758 })
759 }
760
761 Ok(JobResult::Cancel(reason)) => {
762 tracing::Span::current().record("otel.status_code", "OK");
763 info!(
764 job_id = job.id,
765 kind = %job.kind,
766 reason = %reason,
767 "Job cancelled by handler"
768 );
769
770 let kind_specs = enqueue_specs
774 .get(&crate::enqueue_specs::Outcome::Cancelled)
775 .and_then(|by_kind| by_kind.get(&job.kind))
776 .cloned();
777 let outcome_ctx = crate::enqueue_specs::OutcomeContext::Cancelled {
778 reason: reason.clone(),
779 };
780 if let Some(specs) = kind_specs.filter(|s| !s.is_empty()) {
781 let result = cancel_canonical_with_followups(
782 pool,
783 job,
784 reason,
785 progress_snapshot.as_ref(),
786 &specs,
787 &outcome_ctx,
788 )
789 .await?;
790 return match result {
791 None => {
792 warn!(
793 job_id = job.id,
794 "Job already rescued/cancelled, cancel ignored"
795 );
796 Ok(CompletionOutcome::IgnoredStale)
797 }
798 Some(updated_job) => {
799 let event = if needs_event {
800 Some(UntypedJobEvent::Cancelled {
801 job: updated_job,
802 reason: reason.clone(),
803 })
804 } else {
805 None
806 };
807 Ok(CompletionOutcome::Applied {
808 event,
809 terminal: false,
810 })
811 }
812 };
813 }
814
815 let result = sqlx::query(
816 r#"
817 UPDATE awa.jobs
818 SET state = 'cancelled',
819 finalized_at = now(),
820 errors = errors || $2::jsonb,
821 progress = $4
822 WHERE id = $1 AND state = 'running' AND run_lease = $3
823 "#,
824 )
825 .bind(job.id)
826 .bind(serde_json::json!({
827 "error": format!("cancelled: {}", reason),
828 "attempt": job.attempt,
829 "at": chrono::Utc::now().to_rfc3339()
830 }))
831 .bind(job.run_lease)
832 .bind(&progress_snapshot)
833 .execute(pool)
834 .await?;
835 if result.rows_affected() == 0 {
836 warn!(
837 job_id = job.id,
838 "Job already rescued/cancelled, cancel ignored"
839 );
840 return Ok(CompletionOutcome::IgnoredStale);
841 }
842 if needs_event {
843 let updated_job: JobRow = sqlx::query_as("SELECT * FROM awa.jobs WHERE id = $1")
844 .bind(job.id)
845 .fetch_one(pool)
846 .await?;
847 Ok(CompletionOutcome::Applied {
848 event: Some(UntypedJobEvent::Cancelled {
849 job: updated_job,
850 reason: reason.clone(),
851 }),
852 terminal: false,
853 })
854 } else {
855 Ok(CompletionOutcome::Applied {
856 event: None,
857 terminal: false,
858 })
859 }
860 }
861
862 Ok(JobResult::WaitForCallback(_guard)) => {
863 info!(
864 job_id = job.id,
865 kind = %job.kind,
866 "Job waiting for external callback"
867 );
868
869 let kind_specs = enqueue_specs
874 .get(&crate::enqueue_specs::Outcome::WaitingForCallback)
875 .and_then(|by_kind| by_kind.get(&job.kind))
876 .cloned();
877 if let Some(specs) = kind_specs.filter(|s| !s.is_empty()) {
878 let parked =
879 park_canonical_with_followups(pool, job, progress_snapshot.as_ref(), &specs)
880 .await?;
881 if let Some(parked_job) = parked {
882 let event = if needs_event {
883 Some(UntypedJobEvent::WaitingForCallback { job: parked_job })
884 } else {
885 None
886 };
887 return Ok(CompletionOutcome::Applied {
888 event,
889 terminal: false,
890 });
891 }
892 }
894
895 let result = sqlx::query(
896 r#"
897 UPDATE awa.jobs
898 SET state = 'waiting_external',
899 heartbeat_at = NULL,
900 deadline_at = NULL,
901 progress = $3
902 WHERE id = $1 AND state = 'running' AND run_lease = $2 AND callback_id IS NOT NULL
903 "#,
904 )
905 .bind(job.id)
906 .bind(job.run_lease)
907 .bind(&progress_snapshot)
908 .execute(pool)
909 .await?;
910 if result.rows_affected() == 0 {
911 let current: Option<(JobState, Option<uuid::Uuid>)> =
912 sqlx::query_as("SELECT state, callback_id FROM awa.jobs WHERE id = $1")
913 .bind(job.id)
914 .fetch_optional(pool)
915 .await?;
916 match current {
917 Some((state, _)) if state.is_terminal() => {
918 info!(
919 job_id = job.id,
920 state = %state,
921 "Job already completed by racing callback"
922 );
923 return Ok(CompletionOutcome::Applied {
924 event: None,
925 terminal: false,
926 });
927 }
928 Some((_, None)) => {
929 error!(
930 job_id = job.id,
931 "WaitForCallback returned without calling register_callback"
932 );
933 let result = sqlx::query(
934 r#"
935 UPDATE awa.jobs
936 SET state = 'failed',
937 finalized_at = now(),
938 errors = errors || $2::jsonb
939 WHERE id = $1 AND state = 'running' AND run_lease = $3
940 "#,
941 )
942 .bind(job.id)
943 .bind(serde_json::json!({
944 "error": "WaitForCallback returned without calling register_callback",
945 "attempt": job.attempt,
946 "at": chrono::Utc::now().to_rfc3339(),
947 "terminal": true
948 }))
949 .bind(job.run_lease)
950 .execute(pool)
951 .await?;
952 if result.rows_affected() == 0 {
953 return Ok(CompletionOutcome::IgnoredStale);
954 }
955 return Ok(CompletionOutcome::Applied {
956 event: None,
957 terminal: true,
958 });
959 }
960 _ => {
961 warn!(
962 job_id = job.id,
963 "Job already rescued/cancelled, wait-for-callback ignored"
964 );
965 return Ok(CompletionOutcome::IgnoredStale);
966 }
967 }
968 }
969 let event = if needs_event {
970 let parked_job: JobRow = sqlx::query_as("SELECT * FROM awa.jobs WHERE id = $1")
971 .bind(job.id)
972 .fetch_one(pool)
973 .await?;
974 Some(UntypedJobEvent::WaitingForCallback { job: parked_job })
975 } else {
976 None
977 };
978 Ok(CompletionOutcome::Applied {
979 event,
980 terminal: false,
981 })
982 }
983
984 Err(JobError::Terminal(msg)) => {
985 tracing::Span::current().record("otel.status_code", "ERROR");
986 error!(
987 job_id = job.id,
988 kind = %job.kind,
989 error = %msg,
990 "Job failed terminally"
991 );
992
993 let kind_specs = enqueue_specs
996 .get(&crate::enqueue_specs::Outcome::Exhausted)
997 .and_then(|by_kind| by_kind.get(&job.kind))
998 .cloned();
999 if let Some(specs) = kind_specs.filter(|s| !s.is_empty()) {
1000 let result = terminal_canonical_with_followups(
1001 pool,
1002 job,
1003 msg,
1004 progress_snapshot.as_ref(),
1005 &specs,
1006 )
1007 .await?;
1008 return match result {
1009 None => {
1010 warn!(
1011 job_id = job.id,
1012 "Job already rescued/cancelled, terminal failure ignored"
1013 );
1014 Ok(CompletionOutcome::IgnoredStale)
1015 }
1016 Some(updated_job) => {
1017 let event = if needs_event {
1018 Some(UntypedJobEvent::Exhausted {
1019 job: updated_job,
1020 error: msg.clone(),
1021 attempt: job.attempt,
1022 })
1023 } else {
1024 None
1025 };
1026 Ok(CompletionOutcome::Applied {
1027 event,
1028 terminal: true,
1029 })
1030 }
1031 };
1032 }
1033
1034 let result = sqlx::query(
1035 r#"
1036 UPDATE awa.jobs
1037 SET state = 'failed',
1038 finalized_at = now(),
1039 errors = errors || $2::jsonb,
1040 progress = $4
1041 WHERE id = $1 AND state = 'running' AND run_lease = $3
1042 "#,
1043 )
1044 .bind(job.id)
1045 .bind(serde_json::json!({
1046 "error": msg.to_string(),
1047 "attempt": job.attempt,
1048 "at": chrono::Utc::now().to_rfc3339(),
1049 "terminal": true
1050 }))
1051 .bind(job.run_lease)
1052 .bind(&progress_snapshot)
1053 .execute(pool)
1054 .await?;
1055 if result.rows_affected() == 0 {
1056 warn!(
1057 job_id = job.id,
1058 "Job already rescued/cancelled, terminal failure ignored"
1059 );
1060 return Ok(CompletionOutcome::IgnoredStale);
1061 }
1062 if needs_event {
1063 let updated_job: JobRow = sqlx::query_as("SELECT * FROM awa.jobs WHERE id = $1")
1064 .bind(job.id)
1065 .fetch_one(pool)
1066 .await?;
1067 Ok(CompletionOutcome::Applied {
1068 event: Some(UntypedJobEvent::Exhausted {
1069 job: updated_job,
1070 error: msg.clone(),
1071 attempt: job.attempt,
1072 }),
1073 terminal: true,
1074 })
1075 } else {
1076 Ok(CompletionOutcome::Applied {
1077 event: None,
1078 terminal: true,
1079 })
1080 }
1081 }
1082
1083 Err(JobError::Retryable(err)) => {
1084 let error_msg = err.to_string();
1085 if job.attempt >= job.max_attempts {
1086 tracing::Span::current().record("otel.status_code", "ERROR");
1087 error!(
1088 job_id = job.id,
1089 kind = %job.kind,
1090 attempt = job.attempt,
1091 max_attempts = job.max_attempts,
1092 error = %error_msg,
1093 "Job failed (max attempts exhausted)"
1094 );
1095
1096 let kind_specs = enqueue_specs
1098 .get(&crate::enqueue_specs::Outcome::Exhausted)
1099 .and_then(|by_kind| by_kind.get(&job.kind))
1100 .cloned();
1101 if let Some(specs) = kind_specs.filter(|s| !s.is_empty()) {
1102 let result = exhaust_canonical_with_followups(
1103 pool,
1104 job,
1105 &error_msg,
1106 progress_snapshot.as_ref(),
1107 &specs,
1108 )
1109 .await?;
1110 return match result {
1111 None => {
1112 warn!(
1113 job_id = job.id,
1114 "Job already rescued/cancelled, failure ignored"
1115 );
1116 Ok(CompletionOutcome::IgnoredStale)
1117 }
1118 Some(updated_job) => {
1119 let event = if needs_event {
1120 Some(UntypedJobEvent::Exhausted {
1121 job: updated_job,
1122 error: error_msg,
1123 attempt: job.attempt,
1124 })
1125 } else {
1126 None
1127 };
1128 Ok(CompletionOutcome::Applied {
1129 event,
1130 terminal: true,
1131 })
1132 }
1133 };
1134 }
1135
1136 let result = sqlx::query(
1137 r#"
1138 UPDATE awa.jobs
1139 SET state = 'failed',
1140 finalized_at = now(),
1141 errors = errors || $2::jsonb,
1142 progress = $4
1143 WHERE id = $1 AND state = 'running' AND run_lease = $3
1144 "#,
1145 )
1146 .bind(job.id)
1147 .bind(serde_json::json!({
1148 "error": error_msg,
1149 "attempt": job.attempt,
1150 "at": chrono::Utc::now().to_rfc3339()
1151 }))
1152 .bind(job.run_lease)
1153 .bind(&progress_snapshot)
1154 .execute(pool)
1155 .await?;
1156 if result.rows_affected() == 0 {
1157 warn!(
1158 job_id = job.id,
1159 "Job already rescued/cancelled, failure ignored"
1160 );
1161 return Ok(CompletionOutcome::IgnoredStale);
1162 }
1163 if needs_event {
1164 let updated_job: JobRow =
1165 sqlx::query_as("SELECT * FROM awa.jobs WHERE id = $1")
1166 .bind(job.id)
1167 .fetch_one(pool)
1168 .await?;
1169 Ok(CompletionOutcome::Applied {
1170 event: Some(UntypedJobEvent::Exhausted {
1171 job: updated_job,
1172 error: error_msg,
1173 attempt: job.attempt,
1174 }),
1175 terminal: true,
1176 })
1177 } else {
1178 Ok(CompletionOutcome::Applied {
1179 event: None,
1180 terminal: true,
1181 })
1182 }
1183 } else {
1184 warn!(
1185 job_id = job.id,
1186 kind = %job.kind,
1187 attempt = job.attempt,
1188 error = %error_msg,
1189 "Job failed (will retry)"
1190 );
1191
1192 let kind_specs = enqueue_specs
1194 .get(&crate::enqueue_specs::Outcome::Retried)
1195 .and_then(|by_kind| by_kind.get(&job.kind))
1196 .cloned();
1197 if let Some(specs) = kind_specs.filter(|s| !s.is_empty()) {
1198 let result = retry_backoff_canonical_with_followups(
1199 pool,
1200 job,
1201 &error_msg,
1202 progress_snapshot.as_ref(),
1203 &specs,
1204 )
1205 .await?;
1206 return match result {
1207 None => {
1208 warn!(
1209 job_id = job.id,
1210 "Job already rescued/cancelled, retry ignored"
1211 );
1212 Ok(CompletionOutcome::IgnoredStale)
1213 }
1214 Some(updated_job) => {
1215 let event = if needs_event {
1216 Some(UntypedJobEvent::Retried {
1217 job: updated_job.clone(),
1218 error: error_msg,
1219 attempt: job.attempt,
1220 next_run_at: updated_job.run_at,
1221 })
1222 } else {
1223 None
1224 };
1225 Ok(CompletionOutcome::Applied {
1226 event,
1227 terminal: false,
1228 })
1229 }
1230 };
1231 }
1232
1233 let result = sqlx::query(
1234 r#"
1235 UPDATE awa.jobs
1236 SET state = 'retryable',
1237 run_at = now() + awa.backoff_duration($2, $3),
1238 finalized_at = now(),
1239 heartbeat_at = NULL,
1240 deadline_at = NULL,
1241 errors = errors || $4::jsonb,
1242 progress = $6
1243 WHERE id = $1 AND state = 'running' AND run_lease = $5
1244 "#,
1245 )
1246 .bind(job.id)
1247 .bind(job.attempt)
1248 .bind(job.max_attempts)
1249 .bind(serde_json::json!({
1250 "error": error_msg,
1251 "attempt": job.attempt,
1252 "at": chrono::Utc::now().to_rfc3339()
1253 }))
1254 .bind(job.run_lease)
1255 .bind(&progress_snapshot)
1256 .execute(pool)
1257 .await?;
1258 if result.rows_affected() == 0 {
1259 warn!(
1260 job_id = job.id,
1261 "Job already rescued/cancelled, retry ignored"
1262 );
1263 return Ok(CompletionOutcome::IgnoredStale);
1264 }
1265 if needs_event {
1266 let updated_job: JobRow =
1267 sqlx::query_as("SELECT * FROM awa.jobs WHERE id = $1")
1268 .bind(job.id)
1269 .fetch_one(pool)
1270 .await?;
1271 Ok(CompletionOutcome::Applied {
1272 event: Some(UntypedJobEvent::Retried {
1273 job: updated_job.clone(),
1274 error: error_msg,
1275 attempt: job.attempt,
1276 next_run_at: updated_job.run_at,
1277 }),
1278 terminal: false,
1279 })
1280 } else {
1281 Ok(CompletionOutcome::Applied {
1282 event: None,
1283 terminal: false,
1284 })
1285 }
1286 }
1287 }
1288 }
1289}
1290
1291#[allow(clippy::too_many_arguments)]
1292async fn complete_job_queue_storage(
1293 runtime: &QueueStorageRuntime,
1294 pool: &PgPool,
1295 job: &JobRow,
1296 queue_storage_claim: Option<&ClaimedEntry>,
1297 queue_storage_unique_states: Option<&str>,
1298 result: &Result<JobResult, JobError>,
1299 completion_batcher: &CompletionBatcherHandle,
1300 progress_snapshot: Option<serde_json::Value>,
1301 duration: Duration,
1302 needs_event: bool,
1303 enqueue_specs: &Arc<
1304 HashMap<
1305 crate::enqueue_specs::Outcome,
1306 HashMap<String, Vec<crate::enqueue_specs::BoxedEnqueueSpec>>,
1307 >,
1308 >,
1309 dlq_enabled: bool,
1310 metrics: &crate::metrics::AwaMetrics,
1311) -> Result<CompletionOutcome, AwaError> {
1312 match result {
1313 Ok(JobResult::Completed) => {
1314 tracing::Span::current().record("otel.status_code", "OK");
1315 info!(job_id = job.id, kind = %job.kind, attempt = job.attempt, "Job completed");
1316
1317 let kind_specs = enqueue_specs
1325 .get(&crate::enqueue_specs::Outcome::Completed)
1326 .and_then(|by_kind| by_kind.get(&job.kind))
1327 .cloned();
1328 if let Some(specs) = kind_specs.filter(|s| !s.is_empty()) {
1329 let outcome = complete_queue_storage_with_followups(
1330 runtime,
1331 pool,
1332 job,
1333 queue_storage_claim,
1334 queue_storage_unique_states,
1335 &specs,
1336 )
1337 .await?;
1338 return match outcome {
1339 None => {
1340 warn!(
1341 job_id = job.id,
1342 "Job already rescued/cancelled, completion ignored"
1343 );
1344 Ok(CompletionOutcome::IgnoredStale)
1345 }
1346 Some(updated_job) => {
1347 let event = if needs_event {
1348 Some(UntypedJobEvent::Completed {
1349 job: updated_job,
1350 duration,
1351 })
1352 } else {
1353 None
1354 };
1355 Ok(CompletionOutcome::Applied {
1356 event,
1357 terminal: false,
1358 })
1359 }
1360 };
1361 }
1362
1363 let updated = match match queue_storage_claim {
1364 Some(claim) => {
1365 completion_batcher
1366 .complete_runtime_job(ClaimedRuntimeJob {
1367 claim: claim.clone(),
1368 job: job.clone(),
1369 unique_states: queue_storage_unique_states
1370 .map(std::string::ToString::to_string),
1371 })
1372 .await
1373 }
1374 None => completion_batcher.complete(job.id, job.run_lease).await,
1375 } {
1376 Ok(updated) => updated,
1377 Err(err) => {
1378 warn!(
1379 job_id = job.id,
1380 error = %err,
1381 "Completion batch flush failed, falling back to direct finalize"
1382 );
1383 direct_complete_job_queue_storage(
1384 runtime,
1385 pool,
1386 job,
1387 queue_storage_claim,
1388 queue_storage_unique_states,
1389 )
1390 .await?
1391 }
1392 };
1393 if !updated {
1394 warn!(
1395 job_id = job.id,
1396 "Job already rescued/cancelled, completion ignored"
1397 );
1398 return Ok(CompletionOutcome::IgnoredStale);
1399 }
1400 if needs_event {
1401 let updated_job =
1402 runtime
1403 .store
1404 .load_job(pool, job.id)
1405 .await?
1406 .unwrap_or_else(|| {
1407 let mut completed_job = job.clone();
1408 completed_job.state = JobState::Completed;
1409 completed_job.finalized_at = Some(chrono::Utc::now());
1410 completed_job.progress = None;
1411 completed_job
1412 });
1413 Ok(CompletionOutcome::Applied {
1414 event: Some(UntypedJobEvent::Completed {
1415 job: updated_job,
1416 duration,
1417 }),
1418 terminal: false,
1419 })
1420 } else {
1421 Ok(CompletionOutcome::Applied {
1422 event: None,
1423 terminal: false,
1424 })
1425 }
1426 }
1427
1428 Ok(JobResult::RetryAfter(retry_duration)) => {
1429 info!(
1430 job_id = job.id,
1431 kind = %job.kind,
1432 retry_after_secs = retry_duration.as_secs_f64(),
1433 "Job requested retry after duration"
1434 );
1435
1436 let kind_specs = enqueue_specs
1438 .get(&crate::enqueue_specs::Outcome::Retried)
1439 .and_then(|by_kind| by_kind.get(&job.kind))
1440 .cloned();
1441 if let Some(specs) = kind_specs.filter(|s| !s.is_empty()) {
1442 let result = retry_after_queue_storage_with_followups(
1443 runtime,
1444 pool,
1445 job,
1446 *retry_duration,
1447 progress_snapshot.clone(),
1448 &specs,
1449 )
1450 .await?;
1451 return match result {
1452 None => {
1453 warn!(
1454 job_id = job.id,
1455 "Job already rescued/cancelled, retry ignored"
1456 );
1457 Ok(CompletionOutcome::IgnoredStale)
1458 }
1459 Some(updated_job) => {
1460 let event = if needs_event {
1461 Some(UntypedJobEvent::Retried {
1462 job: updated_job.clone(),
1463 error: String::new(),
1464 attempt: updated_job.attempt,
1465 next_run_at: updated_job.run_at,
1466 })
1467 } else {
1468 None
1469 };
1470 Ok(CompletionOutcome::Applied {
1471 event,
1472 terminal: false,
1473 })
1474 }
1475 };
1476 }
1477
1478 let Some(updated_job) = runtime
1479 .store
1480 .retry_after(
1481 pool,
1482 job.id,
1483 job.run_lease,
1484 *retry_duration,
1485 progress_snapshot.clone(),
1486 )
1487 .await?
1488 else {
1489 warn!(
1490 job_id = job.id,
1491 "Job already rescued/cancelled, retry ignored"
1492 );
1493 return Ok(CompletionOutcome::IgnoredStale);
1494 };
1495 if needs_event {
1496 Ok(CompletionOutcome::Applied {
1497 event: Some(UntypedJobEvent::Retried {
1498 job: updated_job.clone(),
1499 error: String::new(),
1500 attempt: updated_job.attempt,
1501 next_run_at: updated_job.run_at,
1502 }),
1503 terminal: false,
1504 })
1505 } else {
1506 Ok(CompletionOutcome::Applied {
1507 event: None,
1508 terminal: false,
1509 })
1510 }
1511 }
1512
1513 Ok(JobResult::Snooze(snooze_duration)) => {
1514 info!(
1515 job_id = job.id,
1516 kind = %job.kind,
1517 snooze_secs = snooze_duration.as_secs_f64(),
1518 "Job snoozed (attempt not incremented)"
1519 );
1520 let updated = runtime
1521 .store
1522 .snooze(
1523 pool,
1524 job.id,
1525 job.run_lease,
1526 *snooze_duration,
1527 progress_snapshot.clone(),
1528 )
1529 .await?;
1530 if updated.is_none() {
1531 warn!(
1532 job_id = job.id,
1533 "Job already rescued/cancelled, snooze ignored"
1534 );
1535 return Ok(CompletionOutcome::IgnoredStale);
1536 }
1537 Ok(CompletionOutcome::Applied {
1538 event: None,
1539 terminal: false,
1540 })
1541 }
1542
1543 Ok(JobResult::Cancel(reason)) => {
1544 info!(
1545 job_id = job.id,
1546 kind = %job.kind,
1547 reason = %reason,
1548 "Job cancelled by handler"
1549 );
1550
1551 let kind_specs = enqueue_specs
1553 .get(&crate::enqueue_specs::Outcome::Cancelled)
1554 .and_then(|by_kind| by_kind.get(&job.kind))
1555 .cloned();
1556 if let Some(specs) = kind_specs.filter(|s| !s.is_empty()) {
1557 let result = cancel_queue_storage_with_followups(
1558 runtime,
1559 pool,
1560 job,
1561 reason,
1562 progress_snapshot.clone(),
1563 &specs,
1564 )
1565 .await?;
1566 return match result {
1567 None => {
1568 warn!(
1569 job_id = job.id,
1570 "Job already rescued/cancelled, cancel ignored"
1571 );
1572 Ok(CompletionOutcome::IgnoredStale)
1573 }
1574 Some(updated_job) => {
1575 let event = if needs_event {
1576 Some(UntypedJobEvent::Cancelled {
1577 job: updated_job,
1578 reason: reason.clone(),
1579 })
1580 } else {
1581 None
1582 };
1583 Ok(CompletionOutcome::Applied {
1584 event,
1585 terminal: false,
1586 })
1587 }
1588 };
1589 }
1590
1591 let Some(updated_job) = runtime
1592 .store
1593 .cancel_running(
1594 pool,
1595 job.id,
1596 job.run_lease,
1597 reason,
1598 progress_snapshot.clone(),
1599 )
1600 .await?
1601 else {
1602 warn!(
1603 job_id = job.id,
1604 "Job already rescued/cancelled, cancel ignored"
1605 );
1606 return Ok(CompletionOutcome::IgnoredStale);
1607 };
1608 if needs_event {
1609 Ok(CompletionOutcome::Applied {
1610 event: Some(UntypedJobEvent::Cancelled {
1611 job: updated_job,
1612 reason: reason.clone(),
1613 }),
1614 terminal: false,
1615 })
1616 } else {
1617 Ok(CompletionOutcome::Applied {
1618 event: None,
1619 terminal: false,
1620 })
1621 }
1622 }
1623
1624 Ok(JobResult::WaitForCallback(guard)) => {
1625 info!(
1626 job_id = job.id,
1627 kind = %job.kind,
1628 "Job waiting for external callback"
1629 );
1630
1631 let kind_specs = enqueue_specs
1635 .get(&crate::enqueue_specs::Outcome::WaitingForCallback)
1636 .and_then(|by_kind| by_kind.get(&job.kind))
1637 .cloned();
1638 if let Some(specs) = kind_specs.filter(|s| !s.is_empty()) {
1639 let parked =
1640 park_queue_storage_with_followups(runtime, pool, job, guard.id(), &specs)
1641 .await?;
1642 if let Some(parked_job) = parked {
1643 let event = if needs_event {
1644 Some(UntypedJobEvent::WaitingForCallback { job: parked_job })
1645 } else {
1646 None
1647 };
1648 return Ok(CompletionOutcome::Applied {
1649 event,
1650 terminal: false,
1651 });
1652 }
1653 }
1655
1656 let entered = runtime
1657 .store
1658 .enter_callback_wait(pool, job.id, job.run_lease, guard.id())
1659 .await?;
1660 if !entered {
1661 let current = runtime.store.load_job(pool, job.id).await?;
1662 match current {
1663 Some(current) if current.state.is_terminal() => {
1664 info!(
1665 job_id = job.id,
1666 state = %current.state,
1667 "Job already completed by racing callback"
1668 );
1669 return Ok(CompletionOutcome::Applied {
1670 event: None,
1671 terminal: false,
1672 });
1673 }
1674 Some(current)
1675 if current.state == JobState::Running && current.callback_id.is_none() =>
1676 {
1677 error!(
1678 job_id = job.id,
1679 "WaitForCallback returned without calling register_callback"
1680 );
1681 let failed = if dlq_enabled {
1682 let failed = runtime
1683 .store
1684 .fail_to_dlq(
1685 pool,
1686 job.id,
1687 job.run_lease,
1688 "wait_for_callback_contract_violation",
1689 "WaitForCallback returned without calling register_callback",
1690 progress_snapshot.clone(),
1691 )
1692 .await?;
1693 if failed.is_some() {
1694 metrics.record_dlq_moved(
1695 &job.kind,
1696 &job.queue,
1697 "wait_for_callback_contract_violation",
1698 );
1699 }
1700 failed
1701 } else {
1702 runtime
1703 .store
1704 .fail_terminal(
1705 pool,
1706 job.id,
1707 job.run_lease,
1708 "WaitForCallback returned without calling register_callback",
1709 progress_snapshot.clone(),
1710 )
1711 .await?
1712 };
1713 if failed.is_none() {
1714 return Ok(CompletionOutcome::IgnoredStale);
1715 }
1716 return Ok(CompletionOutcome::Applied {
1717 event: None,
1718 terminal: true,
1719 });
1720 }
1721 _ => {
1722 warn!(
1723 job_id = job.id,
1724 "Job already rescued/cancelled, wait-for-callback ignored"
1725 );
1726 return Ok(CompletionOutcome::IgnoredStale);
1727 }
1728 }
1729 }
1730 let event = if needs_event {
1731 let parked_job = runtime
1732 .store
1733 .load_job(pool, job.id)
1734 .await?
1735 .unwrap_or_else(|| {
1736 let mut parked = job.clone();
1737 parked.state = JobState::WaitingExternal;
1738 parked
1739 });
1740 Some(UntypedJobEvent::WaitingForCallback { job: parked_job })
1741 } else {
1742 None
1743 };
1744 Ok(CompletionOutcome::Applied {
1745 event,
1746 terminal: false,
1747 })
1748 }
1749
1750 Err(JobError::Terminal(msg)) => {
1751 tracing::Span::current().record("otel.status_code", "ERROR");
1752 error!(
1753 job_id = job.id,
1754 kind = %job.kind,
1755 error = %msg,
1756 "Job failed terminally"
1757 );
1758
1759 let kind_specs = enqueue_specs
1761 .get(&crate::enqueue_specs::Outcome::Exhausted)
1762 .and_then(|by_kind| by_kind.get(&job.kind))
1763 .cloned();
1764 if let Some(specs) = kind_specs.filter(|s| !s.is_empty()) {
1765 let result = fail_queue_storage_with_followups(
1766 runtime,
1767 pool,
1768 job,
1769 "terminal_error",
1770 msg,
1771 progress_snapshot.clone(),
1772 dlq_enabled,
1773 metrics,
1774 &specs,
1775 )
1776 .await?;
1777 return match result {
1778 None => {
1779 warn!(
1780 job_id = job.id,
1781 "Job already rescued/cancelled, terminal failure ignored"
1782 );
1783 Ok(CompletionOutcome::IgnoredStale)
1784 }
1785 Some(updated_job) => {
1786 let event = if needs_event {
1787 Some(UntypedJobEvent::Exhausted {
1788 job: updated_job,
1789 error: msg.clone(),
1790 attempt: job.attempt,
1791 })
1792 } else {
1793 None
1794 };
1795 Ok(CompletionOutcome::Applied {
1796 event,
1797 terminal: true,
1798 })
1799 }
1800 };
1801 }
1802
1803 let updated_job = if dlq_enabled {
1804 let moved = runtime
1805 .store
1806 .fail_to_dlq(
1807 pool,
1808 job.id,
1809 job.run_lease,
1810 "terminal_error",
1811 msg,
1812 progress_snapshot.clone(),
1813 )
1814 .await?;
1815 if moved.is_some() {
1816 metrics.record_dlq_moved(&job.kind, &job.queue, "terminal_error");
1817 }
1818 moved
1819 } else {
1820 runtime
1821 .store
1822 .fail_terminal(pool, job.id, job.run_lease, msg, progress_snapshot.clone())
1823 .await?
1824 };
1825 let Some(updated_job) = updated_job else {
1826 warn!(
1827 job_id = job.id,
1828 "Job already rescued/cancelled, terminal failure ignored"
1829 );
1830 return Ok(CompletionOutcome::IgnoredStale);
1831 };
1832 if needs_event {
1833 Ok(CompletionOutcome::Applied {
1834 event: Some(UntypedJobEvent::Exhausted {
1835 job: updated_job,
1836 error: msg.clone(),
1837 attempt: job.attempt,
1838 }),
1839 terminal: true,
1840 })
1841 } else {
1842 Ok(CompletionOutcome::Applied {
1843 event: None,
1844 terminal: true,
1845 })
1846 }
1847 }
1848
1849 Err(JobError::Retryable(err)) => {
1850 let error_msg = err.to_string();
1851 if job.attempt >= job.max_attempts {
1852 tracing::Span::current().record("otel.status_code", "ERROR");
1853 error!(
1854 job_id = job.id,
1855 kind = %job.kind,
1856 attempt = job.attempt,
1857 max_attempts = job.max_attempts,
1858 error = %error_msg,
1859 "Job failed (max attempts exhausted)"
1860 );
1861
1862 let kind_specs = enqueue_specs
1864 .get(&crate::enqueue_specs::Outcome::Exhausted)
1865 .and_then(|by_kind| by_kind.get(&job.kind))
1866 .cloned();
1867 if let Some(specs) = kind_specs.filter(|s| !s.is_empty()) {
1868 let result = fail_queue_storage_with_followups(
1869 runtime,
1870 pool,
1871 job,
1872 "max_attempts_exhausted",
1873 &error_msg,
1874 progress_snapshot.clone(),
1875 dlq_enabled,
1876 metrics,
1877 &specs,
1878 )
1879 .await?;
1880 return match result {
1881 None => {
1882 warn!(
1883 job_id = job.id,
1884 "Job already rescued/cancelled, failure ignored"
1885 );
1886 Ok(CompletionOutcome::IgnoredStale)
1887 }
1888 Some(updated_job) => {
1889 let event = if needs_event {
1890 Some(UntypedJobEvent::Exhausted {
1891 job: updated_job,
1892 error: error_msg,
1893 attempt: job.attempt,
1894 })
1895 } else {
1896 None
1897 };
1898 Ok(CompletionOutcome::Applied {
1899 event,
1900 terminal: true,
1901 })
1902 }
1903 };
1904 }
1905
1906 let updated_job = if dlq_enabled {
1907 let moved = runtime
1908 .store
1909 .fail_to_dlq(
1910 pool,
1911 job.id,
1912 job.run_lease,
1913 "max_attempts_exhausted",
1914 &error_msg,
1915 progress_snapshot.clone(),
1916 )
1917 .await?;
1918 if moved.is_some() {
1919 metrics.record_dlq_moved(&job.kind, &job.queue, "max_attempts_exhausted");
1920 }
1921 moved
1922 } else {
1923 runtime
1924 .store
1925 .fail_terminal(
1926 pool,
1927 job.id,
1928 job.run_lease,
1929 &error_msg,
1930 progress_snapshot.clone(),
1931 )
1932 .await?
1933 };
1934 let Some(updated_job) = updated_job else {
1935 warn!(
1936 job_id = job.id,
1937 "Job already rescued/cancelled, failure ignored"
1938 );
1939 return Ok(CompletionOutcome::IgnoredStale);
1940 };
1941 if needs_event {
1942 Ok(CompletionOutcome::Applied {
1943 event: Some(UntypedJobEvent::Exhausted {
1944 job: updated_job,
1945 error: error_msg,
1946 attempt: job.attempt,
1947 }),
1948 terminal: true,
1949 })
1950 } else {
1951 Ok(CompletionOutcome::Applied {
1952 event: None,
1953 terminal: true,
1954 })
1955 }
1956 } else {
1957 warn!(
1958 job_id = job.id,
1959 kind = %job.kind,
1960 attempt = job.attempt,
1961 error = %error_msg,
1962 "Job failed (will retry)"
1963 );
1964
1965 let kind_specs = enqueue_specs
1967 .get(&crate::enqueue_specs::Outcome::Retried)
1968 .and_then(|by_kind| by_kind.get(&job.kind))
1969 .cloned();
1970 if let Some(specs) = kind_specs.filter(|s| !s.is_empty()) {
1971 let result = retry_backoff_queue_storage_with_followups(
1972 runtime,
1973 pool,
1974 job,
1975 &error_msg,
1976 progress_snapshot.clone(),
1977 &specs,
1978 )
1979 .await?;
1980 return match result {
1981 None => {
1982 warn!(
1983 job_id = job.id,
1984 "Job already rescued/cancelled, retry ignored"
1985 );
1986 Ok(CompletionOutcome::IgnoredStale)
1987 }
1988 Some(updated_job) => {
1989 let event = if needs_event {
1990 Some(UntypedJobEvent::Retried {
1991 job: updated_job.clone(),
1992 error: error_msg,
1993 attempt: job.attempt,
1994 next_run_at: updated_job.run_at,
1995 })
1996 } else {
1997 None
1998 };
1999 Ok(CompletionOutcome::Applied {
2000 event,
2001 terminal: false,
2002 })
2003 }
2004 };
2005 }
2006
2007 let Some(updated_job) = runtime
2008 .store
2009 .fail_retryable(
2010 pool,
2011 job.id,
2012 job.run_lease,
2013 &error_msg,
2014 progress_snapshot.clone(),
2015 )
2016 .await?
2017 else {
2018 warn!(
2019 job_id = job.id,
2020 "Job already rescued/cancelled, retry ignored"
2021 );
2022 return Ok(CompletionOutcome::IgnoredStale);
2023 };
2024 if needs_event {
2025 Ok(CompletionOutcome::Applied {
2026 event: Some(UntypedJobEvent::Retried {
2027 job: updated_job.clone(),
2028 error: error_msg,
2029 attempt: job.attempt,
2030 next_run_at: updated_job.run_at,
2031 }),
2032 terminal: false,
2033 })
2034 } else {
2035 Ok(CompletionOutcome::Applied {
2036 event: None,
2037 terminal: false,
2038 })
2039 }
2040 }
2041 }
2042 }
2043}
2044
2045#[allow(clippy::explicit_auto_deref)]
2063async fn complete_queue_storage_with_followups(
2064 runtime: &QueueStorageRuntime,
2065 pool: &PgPool,
2066 job: &JobRow,
2067 queue_storage_claim: Option<&ClaimedEntry>,
2068 queue_storage_unique_states: Option<&str>,
2069 specs: &[crate::enqueue_specs::BoxedEnqueueSpec],
2070) -> Result<Option<JobRow>, AwaError> {
2071 let Some(claim) = queue_storage_claim else {
2072 warn!(
2079 job_id = job.id,
2080 "queue-storage completion with follow-up specs but no claim — \
2081 treating as stale"
2082 );
2083 return Ok(None);
2084 };
2085
2086 let runtime_job = ClaimedRuntimeJob {
2087 claim: claim.clone(),
2088 job: job.clone(),
2089 unique_states: queue_storage_unique_states.map(std::string::ToString::to_string),
2090 };
2091
2092 let mut tx = pool.begin().await?;
2093
2094 let updated = runtime
2095 .store
2096 .complete_runtime_batch_slow_in_tx(&mut tx, std::slice::from_ref(&runtime_job))
2097 .await?;
2098
2099 if updated.is_empty() {
2100 tx.rollback().await?;
2101 return Ok(None);
2102 }
2103
2104 let mut updated_job = job.clone();
2109 updated_job.state = JobState::Completed;
2110 updated_job.finalized_at = Some(chrono::Utc::now());
2111 updated_job.progress = None;
2112
2113 crate::enqueue_specs::dispatch_specs_in_tx(&mut tx, &updated_job, specs, None).await?;
2114
2115 tx.commit().await?;
2116 Ok(Some(updated_job))
2117}
2118
2119async fn cancel_queue_storage_with_followups(
2122 runtime: &QueueStorageRuntime,
2123 pool: &PgPool,
2124 job: &JobRow,
2125 reason: &str,
2126 progress_snapshot: Option<serde_json::Value>,
2127 specs: &[crate::enqueue_specs::BoxedEnqueueSpec],
2128) -> Result<Option<JobRow>, AwaError> {
2129 let mut tx = pool.begin().await?;
2130 let Some(updated_job) = runtime
2131 .store
2132 .cancel_running_in_tx(&mut tx, job.id, job.run_lease, reason, progress_snapshot)
2133 .await?
2134 else {
2135 tx.rollback().await?;
2136 return Ok(None);
2137 };
2138 let outcome_ctx = crate::enqueue_specs::OutcomeContext::Cancelled {
2139 reason: reason.to_string(),
2140 };
2141 crate::enqueue_specs::dispatch_specs_in_tx(&mut tx, &updated_job, specs, Some(&outcome_ctx))
2142 .await?;
2143 tx.commit().await?;
2144 Ok(Some(updated_job))
2145}
2146
2147async fn retry_after_queue_storage_with_followups(
2149 runtime: &QueueStorageRuntime,
2150 pool: &PgPool,
2151 job: &JobRow,
2152 retry_duration: Duration,
2153 progress_snapshot: Option<serde_json::Value>,
2154 specs: &[crate::enqueue_specs::BoxedEnqueueSpec],
2155) -> Result<Option<JobRow>, AwaError> {
2156 let mut tx = pool.begin().await?;
2157 let Some(updated_job) = runtime
2158 .store
2159 .retry_after_in_tx(
2160 &mut tx,
2161 job.id,
2162 job.run_lease,
2163 retry_duration,
2164 progress_snapshot,
2165 )
2166 .await?
2167 else {
2168 tx.rollback().await?;
2169 return Ok(None);
2170 };
2171 let outcome_ctx = crate::enqueue_specs::OutcomeContext::Retried {
2173 error: String::new(),
2174 attempt: updated_job.attempt,
2175 next_run_at: updated_job.run_at,
2176 };
2177 crate::enqueue_specs::dispatch_specs_in_tx(&mut tx, &updated_job, specs, Some(&outcome_ctx))
2178 .await?;
2179 tx.commit().await?;
2180 Ok(Some(updated_job))
2181}
2182
2183async fn retry_backoff_queue_storage_with_followups(
2186 runtime: &QueueStorageRuntime,
2187 pool: &PgPool,
2188 job: &JobRow,
2189 error_msg: &str,
2190 progress_snapshot: Option<serde_json::Value>,
2191 specs: &[crate::enqueue_specs::BoxedEnqueueSpec],
2192) -> Result<Option<JobRow>, AwaError> {
2193 let mut tx = pool.begin().await?;
2194 let Some(updated_job) = runtime
2195 .store
2196 .fail_retryable_in_tx(&mut tx, job.id, job.run_lease, error_msg, progress_snapshot)
2197 .await?
2198 else {
2199 tx.rollback().await?;
2200 return Ok(None);
2201 };
2202 let outcome_ctx = crate::enqueue_specs::OutcomeContext::Retried {
2203 error: error_msg.to_string(),
2204 attempt: job.attempt,
2205 next_run_at: updated_job.run_at,
2206 };
2207 crate::enqueue_specs::dispatch_specs_in_tx(&mut tx, &updated_job, specs, Some(&outcome_ctx))
2208 .await?;
2209 tx.commit().await?;
2210 Ok(Some(updated_job))
2211}
2212
2213#[allow(clippy::too_many_arguments)]
2216async fn fail_queue_storage_with_followups(
2217 runtime: &QueueStorageRuntime,
2218 pool: &PgPool,
2219 job: &JobRow,
2220 dlq_reason: &str,
2221 error_msg: &str,
2222 progress_snapshot: Option<serde_json::Value>,
2223 dlq_enabled: bool,
2224 metrics: &crate::metrics::AwaMetrics,
2225 specs: &[crate::enqueue_specs::BoxedEnqueueSpec],
2226) -> Result<Option<JobRow>, AwaError> {
2227 let mut tx = pool.begin().await?;
2228 let (updated_job, routed_to_dlq) = if dlq_enabled {
2233 let moved = runtime
2234 .store
2235 .fail_to_dlq_in_tx(
2236 &mut tx,
2237 job.id,
2238 job.run_lease,
2239 dlq_reason,
2240 error_msg,
2241 progress_snapshot,
2242 )
2243 .await?;
2244 let routed = moved.is_some();
2245 (moved, routed)
2246 } else {
2247 let moved = runtime
2248 .store
2249 .fail_terminal_in_tx(&mut tx, job.id, job.run_lease, error_msg, progress_snapshot)
2250 .await?;
2251 (moved, false)
2252 };
2253 let Some(updated_job) = updated_job else {
2254 tx.rollback().await?;
2255 return Ok(None);
2256 };
2257 let outcome_ctx = crate::enqueue_specs::OutcomeContext::Exhausted {
2258 error: error_msg.to_string(),
2259 attempt: job.attempt,
2260 };
2261 crate::enqueue_specs::dispatch_specs_in_tx(&mut tx, &updated_job, specs, Some(&outcome_ctx))
2262 .await?;
2263 tx.commit().await?;
2264 if routed_to_dlq {
2265 metrics.record_dlq_moved(&job.kind, &job.queue, dlq_reason);
2266 }
2267 Ok(Some(updated_job))
2268}
2269
2270async fn park_queue_storage_with_followups(
2277 runtime: &QueueStorageRuntime,
2278 pool: &PgPool,
2279 job: &JobRow,
2280 callback_id: uuid::Uuid,
2281 specs: &[crate::enqueue_specs::BoxedEnqueueSpec],
2282) -> Result<Option<JobRow>, AwaError> {
2283 let mut tx = pool.begin().await?;
2284 let entered = runtime
2285 .store
2286 .enter_callback_wait_in_tx(&mut tx, job.id, job.run_lease, callback_id)
2287 .await?;
2288 if !entered {
2289 tx.rollback().await?;
2290 return Ok(None);
2291 }
2292 let parked_job = match runtime
2301 .store
2302 .load_active_lease_in_tx(&mut tx, job.id, job.run_lease)
2303 .await?
2304 {
2305 Some(row) => row,
2306 None => {
2307 let mut parked = job.clone();
2308 parked.state = JobState::WaitingExternal;
2309 parked.heartbeat_at = None;
2310 parked.deadline_at = None;
2311 parked.callback_id = Some(callback_id);
2312 parked
2313 }
2314 };
2315 crate::enqueue_specs::dispatch_specs_in_tx(
2316 &mut tx,
2317 &parked_job,
2318 specs,
2319 Some(&crate::enqueue_specs::OutcomeContext::WaitingForCallback),
2320 )
2321 .await?;
2322 tx.commit().await?;
2323 Ok(Some(parked_job))
2324}
2325
2326async fn direct_complete_job_queue_storage(
2327 runtime: &QueueStorageRuntime,
2328 pool: &PgPool,
2329 job: &JobRow,
2330 queue_storage_claim: Option<&ClaimedEntry>,
2331 queue_storage_unique_states: Option<&str>,
2332) -> Result<bool, AwaError> {
2333 let updated = if let Some(claim) = queue_storage_claim {
2334 let runtime_job = ClaimedRuntimeJob {
2335 claim: claim.clone(),
2336 job: job.clone(),
2337 unique_states: queue_storage_unique_states.map(std::string::ToString::to_string),
2338 };
2339 runtime
2340 .store
2341 .complete_runtime_batch(pool, std::slice::from_ref(&runtime_job))
2342 .await?
2343 } else {
2344 runtime
2345 .store
2346 .complete_job_batch_by_id(pool, &[(job.id, job.run_lease)])
2347 .await?
2348 };
2349 Ok(!updated.is_empty())
2350}
2351
2352pub(crate) async fn dispatch_lifecycle_event(
2358 handlers: &HashMap<String, Vec<BoxedUntypedEventHandler>>,
2359 kind: &str,
2360 event: UntypedJobEvent,
2361) {
2362 if let Some(handlers) = handlers.get(kind) {
2363 for handler in handlers {
2364 let handler = handler.clone();
2365 let event = event.clone();
2366 let result = tokio::spawn(async move {
2367 (handler)(event).await;
2368 })
2369 .await;
2370 if let Err(err) = result {
2371 tracing::warn!(
2372 kind,
2373 error = %err,
2374 "Lifecycle event handler panicked"
2375 );
2376 }
2377 }
2378 }
2379}
2380
2381async fn direct_complete_job(pool: &PgPool, job: &JobRow) -> Result<bool, AwaError> {
2382 let result = sqlx::query(
2383 r#"
2384 UPDATE awa.jobs_hot
2385 SET state = 'completed',
2386 finalized_at = now(),
2387 progress = NULL
2388 WHERE id = $1 AND state = 'running' AND run_lease = $2
2389 "#,
2390 )
2391 .bind(job.id)
2392 .bind(job.run_lease)
2393 .execute(pool)
2394 .await?;
2395
2396 Ok(result.rows_affected() > 0)
2397}
2398
2399#[allow(clippy::explicit_auto_deref)]
2413async fn complete_canonical_with_followups(
2414 pool: &PgPool,
2415 job: &JobRow,
2416 specs: &[crate::enqueue_specs::BoxedEnqueueSpec],
2417) -> Result<Option<JobRow>, AwaError> {
2418 let mut tx = pool.begin().await?;
2419
2420 let updated_job: Option<JobRow> = sqlx::query_as(
2427 r#"
2428 UPDATE awa.jobs_hot
2429 SET state = 'completed',
2430 finalized_at = now(),
2431 progress = NULL
2432 WHERE id = $1 AND state = 'running' AND run_lease = $2
2433 RETURNING *
2434 "#,
2435 )
2436 .bind(job.id)
2437 .bind(job.run_lease)
2438 .fetch_optional(&mut *tx)
2439 .await?;
2440
2441 let Some(updated_job) = updated_job else {
2442 tx.rollback().await?;
2445 return Ok(None);
2446 };
2447
2448 crate::enqueue_specs::dispatch_specs_in_tx(&mut tx, &updated_job, specs, None).await?;
2449
2450 tx.commit().await?;
2451 Ok(Some(updated_job))
2452}
2453
2454async fn cancel_canonical_with_followups(
2459 pool: &PgPool,
2460 job: &JobRow,
2461 reason: &str,
2462 progress_snapshot: Option<&serde_json::Value>,
2463 specs: &[crate::enqueue_specs::BoxedEnqueueSpec],
2464 outcome_ctx: &crate::enqueue_specs::OutcomeContext,
2465) -> Result<Option<JobRow>, AwaError> {
2466 let mut tx = pool.begin().await?;
2467
2468 let updated_job: Option<JobRow> = sqlx::query_as(
2469 r#"
2470 UPDATE awa.jobs_hot
2471 SET state = 'cancelled',
2472 finalized_at = now(),
2473 errors = errors || $2::jsonb,
2474 progress = $4
2475 WHERE id = $1 AND state = 'running' AND run_lease = $3
2476 RETURNING *
2477 "#,
2478 )
2479 .bind(job.id)
2480 .bind(serde_json::json!({
2481 "error": format!("cancelled: {}", reason),
2482 "attempt": job.attempt,
2483 "at": chrono::Utc::now().to_rfc3339()
2484 }))
2485 .bind(job.run_lease)
2486 .bind(progress_snapshot)
2487 .fetch_optional(&mut *tx)
2488 .await?;
2489
2490 let Some(updated_job) = updated_job else {
2491 tx.rollback().await?;
2492 return Ok(None);
2493 };
2494
2495 crate::enqueue_specs::dispatch_specs_in_tx(&mut tx, &updated_job, specs, Some(outcome_ctx))
2496 .await?;
2497
2498 tx.commit().await?;
2499 Ok(Some(updated_job))
2500}
2501
2502async fn park_canonical_with_followups(
2508 pool: &PgPool,
2509 job: &JobRow,
2510 progress_snapshot: Option<&serde_json::Value>,
2511 specs: &[crate::enqueue_specs::BoxedEnqueueSpec],
2512) -> Result<Option<JobRow>, AwaError> {
2513 let mut tx = pool.begin().await?;
2514
2515 let parked_job: Option<JobRow> = sqlx::query_as(
2516 r#"
2517 UPDATE awa.jobs_hot
2518 SET state = 'waiting_external',
2519 heartbeat_at = NULL,
2520 deadline_at = NULL,
2521 progress = $3
2522 WHERE id = $1 AND state = 'running' AND run_lease = $2 AND callback_id IS NOT NULL
2523 RETURNING *
2524 "#,
2525 )
2526 .bind(job.id)
2527 .bind(job.run_lease)
2528 .bind(progress_snapshot)
2529 .fetch_optional(&mut *tx)
2530 .await?;
2531
2532 let Some(parked_job) = parked_job else {
2533 tx.rollback().await?;
2534 return Ok(None);
2535 };
2536
2537 crate::enqueue_specs::dispatch_specs_in_tx(
2538 &mut tx,
2539 &parked_job,
2540 specs,
2541 Some(&crate::enqueue_specs::OutcomeContext::WaitingForCallback),
2542 )
2543 .await?;
2544
2545 tx.commit().await?;
2546 Ok(Some(parked_job))
2547}
2548
2549async fn retry_after_canonical_with_followups(
2559 pool: &PgPool,
2560 job: &JobRow,
2561 seconds: f64,
2562 progress_snapshot: Option<&serde_json::Value>,
2563 specs: &[crate::enqueue_specs::BoxedEnqueueSpec],
2564) -> Result<Option<JobRow>, AwaError> {
2565 let mut tx = pool.begin().await?;
2566
2567 let updated_job: Option<JobRow> = sqlx::query_as(
2568 r#"
2569 WITH deleted AS (
2570 DELETE FROM awa.jobs_hot
2571 WHERE id = $1 AND state = 'running' AND run_lease = $3
2572 RETURNING *
2573 )
2574 INSERT INTO awa.scheduled_jobs (
2575 id, kind, queue, args, state, priority, attempt, max_attempts,
2576 run_at, heartbeat_at, deadline_at, attempted_at, finalized_at,
2577 created_at, errors, metadata, tags, unique_key, unique_states,
2578 callback_id, callback_timeout_at, callback_filter,
2579 callback_on_complete, callback_on_fail, callback_transform,
2580 run_lease, progress
2581 )
2582 SELECT
2583 id, kind, queue, args,
2584 'retryable'::awa.job_state,
2585 priority, attempt, max_attempts,
2586 now() + make_interval(secs => $2),
2587 heartbeat_at, deadline_at, attempted_at,
2588 now(),
2589 created_at, errors, metadata, tags, unique_key, unique_states,
2590 callback_id, callback_timeout_at, callback_filter,
2591 callback_on_complete, callback_on_fail, callback_transform,
2592 run_lease, $4
2593 FROM deleted
2594 RETURNING *
2595 "#,
2596 )
2597 .bind(job.id)
2598 .bind(seconds)
2599 .bind(job.run_lease)
2600 .bind(progress_snapshot)
2601 .fetch_optional(&mut *tx)
2602 .await?;
2603
2604 let Some(updated_job) = updated_job else {
2605 tx.rollback().await?;
2606 return Ok(None);
2607 };
2608
2609 let outcome_ctx = crate::enqueue_specs::OutcomeContext::Retried {
2610 error: String::new(),
2611 attempt: updated_job.attempt,
2612 next_run_at: updated_job.run_at,
2613 };
2614 crate::enqueue_specs::dispatch_specs_in_tx(&mut tx, &updated_job, specs, Some(&outcome_ctx))
2615 .await?;
2616
2617 tx.commit().await?;
2618 Ok(Some(updated_job))
2619}
2620
2621async fn terminal_canonical_with_followups(
2623 pool: &PgPool,
2624 job: &JobRow,
2625 msg: &str,
2626 progress_snapshot: Option<&serde_json::Value>,
2627 specs: &[crate::enqueue_specs::BoxedEnqueueSpec],
2628) -> Result<Option<JobRow>, AwaError> {
2629 let mut tx = pool.begin().await?;
2630
2631 let updated_job: Option<JobRow> = sqlx::query_as(
2632 r#"
2633 UPDATE awa.jobs_hot
2634 SET state = 'failed',
2635 finalized_at = now(),
2636 errors = errors || $2::jsonb,
2637 progress = $4
2638 WHERE id = $1 AND state = 'running' AND run_lease = $3
2639 RETURNING *
2640 "#,
2641 )
2642 .bind(job.id)
2643 .bind(serde_json::json!({
2644 "error": msg,
2645 "attempt": job.attempt,
2646 "at": chrono::Utc::now().to_rfc3339(),
2647 "terminal": true
2648 }))
2649 .bind(job.run_lease)
2650 .bind(progress_snapshot)
2651 .fetch_optional(&mut *tx)
2652 .await?;
2653
2654 let Some(updated_job) = updated_job else {
2655 tx.rollback().await?;
2656 return Ok(None);
2657 };
2658
2659 let outcome_ctx = crate::enqueue_specs::OutcomeContext::Exhausted {
2660 error: msg.to_string(),
2661 attempt: job.attempt,
2662 };
2663 crate::enqueue_specs::dispatch_specs_in_tx(&mut tx, &updated_job, specs, Some(&outcome_ctx))
2664 .await?;
2665
2666 tx.commit().await?;
2667 Ok(Some(updated_job))
2668}
2669
2670async fn exhaust_canonical_with_followups(
2674 pool: &PgPool,
2675 job: &JobRow,
2676 error_msg: &str,
2677 progress_snapshot: Option<&serde_json::Value>,
2678 specs: &[crate::enqueue_specs::BoxedEnqueueSpec],
2679) -> Result<Option<JobRow>, AwaError> {
2680 let mut tx = pool.begin().await?;
2681
2682 let updated_job: Option<JobRow> = sqlx::query_as(
2683 r#"
2684 UPDATE awa.jobs_hot
2685 SET state = 'failed',
2686 finalized_at = now(),
2687 errors = errors || $2::jsonb,
2688 progress = $4
2689 WHERE id = $1 AND state = 'running' AND run_lease = $3
2690 RETURNING *
2691 "#,
2692 )
2693 .bind(job.id)
2694 .bind(serde_json::json!({
2695 "error": error_msg,
2696 "attempt": job.attempt,
2697 "at": chrono::Utc::now().to_rfc3339()
2698 }))
2699 .bind(job.run_lease)
2700 .bind(progress_snapshot)
2701 .fetch_optional(&mut *tx)
2702 .await?;
2703
2704 let Some(updated_job) = updated_job else {
2705 tx.rollback().await?;
2706 return Ok(None);
2707 };
2708
2709 let outcome_ctx = crate::enqueue_specs::OutcomeContext::Exhausted {
2710 error: error_msg.to_string(),
2711 attempt: job.attempt,
2712 };
2713 crate::enqueue_specs::dispatch_specs_in_tx(&mut tx, &updated_job, specs, Some(&outcome_ctx))
2714 .await?;
2715
2716 tx.commit().await?;
2717 Ok(Some(updated_job))
2718}
2719
2720async fn retry_backoff_canonical_with_followups(
2723 pool: &PgPool,
2724 job: &JobRow,
2725 error_msg: &str,
2726 progress_snapshot: Option<&serde_json::Value>,
2727 specs: &[crate::enqueue_specs::BoxedEnqueueSpec],
2728) -> Result<Option<JobRow>, AwaError> {
2729 let mut tx = pool.begin().await?;
2730
2731 let updated_job: Option<JobRow> = sqlx::query_as(
2732 r#"
2733 WITH deleted AS (
2734 DELETE FROM awa.jobs_hot
2735 WHERE id = $1 AND state = 'running' AND run_lease = $5
2736 RETURNING *
2737 )
2738 INSERT INTO awa.scheduled_jobs (
2739 id, kind, queue, args, state, priority, attempt, max_attempts,
2740 run_at, heartbeat_at, deadline_at, attempted_at, finalized_at,
2741 created_at, errors, metadata, tags, unique_key, unique_states,
2742 callback_id, callback_timeout_at, callback_filter,
2743 callback_on_complete, callback_on_fail, callback_transform,
2744 run_lease, progress
2745 )
2746 SELECT
2747 id, kind, queue, args,
2748 'retryable'::awa.job_state,
2749 priority, attempt, max_attempts,
2750 now() + awa.backoff_duration($2, $3),
2751 NULL, NULL,
2752 attempted_at,
2753 now(),
2754 created_at,
2755 errors || $4::jsonb,
2756 metadata, tags, unique_key, unique_states,
2757 callback_id, callback_timeout_at, callback_filter,
2758 callback_on_complete, callback_on_fail, callback_transform,
2759 run_lease, $6
2760 FROM deleted
2761 RETURNING *
2762 "#,
2763 )
2764 .bind(job.id)
2765 .bind(job.attempt)
2766 .bind(job.max_attempts)
2767 .bind(serde_json::json!({
2768 "error": error_msg,
2769 "attempt": job.attempt,
2770 "at": chrono::Utc::now().to_rfc3339()
2771 }))
2772 .bind(job.run_lease)
2773 .bind(progress_snapshot)
2774 .fetch_optional(&mut *tx)
2775 .await?;
2776
2777 let Some(updated_job) = updated_job else {
2778 tx.rollback().await?;
2779 return Ok(None);
2780 };
2781
2782 let outcome_ctx = crate::enqueue_specs::OutcomeContext::Retried {
2783 error: error_msg.to_string(),
2784 attempt: job.attempt,
2785 next_run_at: updated_job.run_at,
2786 };
2787 crate::enqueue_specs::dispatch_specs_in_tx(&mut tx, &updated_job, specs, Some(&outcome_ctx))
2788 .await?;
2789
2790 tx.commit().await?;
2791 Ok(Some(updated_job))
2792}