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