1use crate::completion::CompletionBatcherHandle;
2use crate::context::{CallbackGuard, JobContext};
3use crate::events::{BoxedUntypedEventHandler, UntypedJobEvent};
4use crate::runtime::{InFlightMap, InFlightState, ProgressState};
5use awa_model::{AwaError, JobRow};
6use sqlx::PgPool;
7use std::any::Any;
8use std::collections::HashMap;
9use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
10use std::sync::Arc;
11use std::time::Duration;
12use tracing::{error, info, info_span, warn, Instrument};
13
14#[derive(Debug)]
20pub enum JobResult {
21 Completed,
23 RetryAfter(std::time::Duration),
25 Snooze(std::time::Duration),
27 Cancel(String),
29 WaitForCallback(CallbackGuard),
34}
35
36#[derive(Debug, thiserror::Error)]
41pub enum JobError {
42 #[error("{0}")]
44 Retryable(#[source] Box<dyn std::error::Error + Send + Sync>),
45
46 #[error("terminal: {0}")]
48 Terminal(String),
49}
50
51impl JobError {
52 pub fn retryable(err: impl std::error::Error + Send + Sync + 'static) -> Self {
54 JobError::Retryable(Box::new(err))
55 }
56
57 pub fn retryable_msg(msg: impl std::fmt::Display) -> Self {
67 JobError::Retryable(Box::new(DisplayError(msg.to_string())))
68 }
69
70 pub fn terminal(msg: impl Into<String>) -> Self {
72 JobError::Terminal(msg.into())
73 }
74}
75
76#[derive(Debug)]
78struct DisplayError(String);
79
80impl std::fmt::Display for DisplayError {
81 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82 f.write_str(&self.0)
83 }
84}
85
86impl std::error::Error for DisplayError {}
87
88#[cfg(feature = "anyhow")]
96impl From<anyhow::Error> for JobError {
97 fn from(err: anyhow::Error) -> Self {
98 JobError::retryable_msg(format!("{err:#}"))
99 }
100}
101
102#[async_trait::async_trait]
124pub trait Worker: Send + Sync + 'static {
125 fn kind(&self) -> &'static str;
127
128 async fn perform(&self, ctx: &JobContext) -> Result<JobResult, JobError>;
130}
131
132pub(crate) type BoxedWorker = Box<dyn Worker>;
134
135#[allow(clippy::large_enum_variant)]
137enum CompletionOutcome {
138 Applied { event: Option<UntypedJobEvent> },
140 IgnoredStale,
142}
143
144pub struct JobExecutor {
146 pool: PgPool,
147 workers: Arc<HashMap<String, BoxedWorker>>,
148 lifecycle_handlers: Arc<HashMap<String, Vec<BoxedUntypedEventHandler>>>,
149 in_flight: InFlightMap,
150 queue_in_flight: Arc<HashMap<String, Arc<AtomicU32>>>,
151 state: Arc<HashMap<std::any::TypeId, Box<dyn Any + Send + Sync>>>,
152 metrics: crate::metrics::AwaMetrics,
153 completion_batcher: CompletionBatcherHandle,
154}
155
156impl JobExecutor {
157 #[allow(clippy::too_many_arguments)]
158 pub(crate) fn new(
159 pool: PgPool,
160 workers: Arc<HashMap<String, BoxedWorker>>,
161 lifecycle_handlers: Arc<HashMap<String, Vec<BoxedUntypedEventHandler>>>,
162 in_flight: InFlightMap,
163 queue_in_flight: Arc<HashMap<String, Arc<AtomicU32>>>,
164 state: Arc<HashMap<std::any::TypeId, Box<dyn Any + Send + Sync>>>,
165 metrics: crate::metrics::AwaMetrics,
166 completion_batcher: CompletionBatcherHandle,
167 ) -> Self {
168 Self {
169 pool,
170 workers,
171 lifecycle_handlers,
172 in_flight,
173 queue_in_flight,
174 state,
175 metrics,
176 completion_batcher,
177 }
178 }
179
180 pub fn execute_task(
184 &self,
185 job: JobRow,
186 cancel: Arc<AtomicBool>,
187 ) -> impl std::future::Future<Output = ()> + Send + 'static {
188 let pool = self.pool.clone();
189 let workers = self.workers.clone();
190 let lifecycle_handlers = self.lifecycle_handlers.clone();
191 let in_flight = self.in_flight.clone();
192 let queue_in_flight = self.queue_in_flight.clone();
193 let state = self.state.clone();
194 let metrics = self.metrics.clone();
195 let completion_batcher = self.completion_batcher.clone();
196 let job_id = job.id;
197 let job_run_lease = job.run_lease;
198 let job_kind = job.kind.clone();
199 let job_queue = job.queue.clone();
200
201 let span = info_span!(
202 "job.execute",
203 job.id = job_id,
204 job.kind = %job_kind,
205 job.queue = %job_queue,
206 job.attempt = job.attempt,
207 otel.name = %format!("job.execute {}", job_kind),
208 otel.status_code = tracing::field::Empty,
209 );
210
211 async move {
212 let progress_state = Arc::new(std::sync::Mutex::new(ProgressState::new(
214 job.progress.clone(),
215 )));
216
217 let in_flight_state = InFlightState {
219 cancel: cancel.clone(),
220 progress: progress_state.clone(),
221 };
222 in_flight.insert((job_id, job_run_lease), in_flight_state);
223 if let Some(counter) = queue_in_flight.get(&job_queue) {
224 counter.fetch_add(1, Ordering::SeqCst);
225 }
226 metrics.record_in_flight_change(&job_queue, 1);
227
228 let start = std::time::Instant::now();
229 let ctx = JobContext::new(
230 job.clone(),
231 cancel,
232 state,
233 pool.clone(),
234 progress_state.clone(),
235 );
236
237 let result = match workers.get(&job.kind) {
238 Some(worker) => worker.perform(&ctx).await,
239 None => {
240 error!(kind = %job.kind, job_id, "No worker registered for job kind");
241 Err(JobError::Terminal(format!(
242 "unknown job kind: {}",
243 job.kind
244 )))
245 }
246 };
247
248 let duration = start.elapsed();
249
250 let progress_snapshot = {
252 let guard = progress_state.lock().expect("progress lock poisoned");
253 guard.clone_latest()
254 };
255
256 let has_lifecycle_handlers = lifecycle_handlers.contains_key(&job_kind);
259 let outcome = complete_job(
260 &pool,
261 &job,
262 &result,
263 &completion_batcher,
264 progress_snapshot,
265 duration,
266 has_lifecycle_handlers,
267 )
268 .await;
269
270 match &outcome {
271 Ok(CompletionOutcome::Applied { .. }) => {
272 match &result {
274 Ok(JobResult::Completed) => {
275 metrics.record_job_completed(&job_kind, &job_queue, duration);
276 }
277 Ok(JobResult::RetryAfter(_)) => {
278 metrics.record_job_retried(&job_kind, &job_queue);
279 }
280 Ok(JobResult::Cancel(_)) => {
281 metrics.jobs_cancelled.add(
282 1,
283 &[
284 opentelemetry::KeyValue::new("awa.job.kind", job_kind.clone()),
285 opentelemetry::KeyValue::new(
286 "awa.job.queue",
287 job_queue.clone(),
288 ),
289 ],
290 );
291 }
292 Ok(JobResult::Snooze(_)) => {} Ok(JobResult::WaitForCallback(_)) => {
294 metrics.jobs_waiting_external.add(
295 1,
296 &[
297 opentelemetry::KeyValue::new("awa.job.kind", job_kind.clone()),
298 opentelemetry::KeyValue::new(
299 "awa.job.queue",
300 job_queue.clone(),
301 ),
302 ],
303 );
304 }
305 Err(JobError::Terminal(_)) => {
306 metrics.record_job_failed(&job_kind, &job_queue, true);
307 }
308 Err(JobError::Retryable(_)) => {
309 metrics.record_job_retried(&job_kind, &job_queue);
310 }
311 }
312 }
313 Ok(CompletionOutcome::IgnoredStale) => {
314 }
316 Err(err) => {
317 error!(job_id, error = %err, "Failed to complete job");
318 }
319 }
320
321 in_flight.remove((job_id, job_run_lease));
325 if let Some(counter) = queue_in_flight.get(&job_queue) {
326 counter.fetch_sub(1, Ordering::SeqCst);
327 }
328 metrics.record_in_flight_change(&job_queue, -1);
329
330 if let Ok(CompletionOutcome::Applied {
333 event: Some(event), ..
334 }) = outcome
335 {
336 let handlers = lifecycle_handlers.clone();
337 let kind = job_kind.clone();
338 tokio::spawn(async move {
339 dispatch_lifecycle_event(&handlers, &kind, event).await;
340 });
341 }
342 }
343 .instrument(span)
344 }
345}
346
347async fn complete_job(
352 pool: &PgPool,
353 job: &JobRow,
354 result: &Result<JobResult, JobError>,
355 completion_batcher: &CompletionBatcherHandle,
356 progress_snapshot: Option<serde_json::Value>,
357 duration: Duration,
358 needs_event: bool,
359) -> Result<CompletionOutcome, AwaError> {
360 match result {
361 Ok(JobResult::Completed) => {
362 tracing::Span::current().record("otel.status_code", "OK");
363 info!(job_id = job.id, kind = %job.kind, attempt = job.attempt, "Job completed");
364 let result = match completion_batcher.complete(job.id, job.run_lease).await {
365 Ok(updated) => updated,
366 Err(err) => {
367 warn!(
368 job_id = job.id,
369 error = %err,
370 "Completion batch flush failed, falling back to direct finalize"
371 );
372 direct_complete_job(pool, job).await?
373 }
374 };
375 if !result {
376 warn!(
377 job_id = job.id,
378 "Job already rescued/cancelled, completion ignored"
379 );
380 return Ok(CompletionOutcome::IgnoredStale);
381 }
382 if needs_event {
383 let updated_job: JobRow = sqlx::query_as("SELECT * FROM awa.jobs WHERE id = $1")
384 .bind(job.id)
385 .fetch_one(pool)
386 .await?;
387 Ok(CompletionOutcome::Applied {
388 event: Some(UntypedJobEvent::Completed {
389 job: updated_job,
390 duration,
391 }),
392 })
393 } else {
394 Ok(CompletionOutcome::Applied { event: None })
395 }
396 }
397
398 Ok(JobResult::RetryAfter(retry_duration)) => {
399 let seconds = retry_duration.as_secs() as f64;
400 info!(
401 job_id = job.id,
402 kind = %job.kind,
403 retry_after_secs = seconds,
404 "Job requested retry after duration"
405 );
406 let result = sqlx::query(
407 r#"
408 UPDATE awa.jobs
409 SET state = 'retryable',
410 run_at = now() + make_interval(secs => $2),
411 finalized_at = now(),
412 progress = $4
413 WHERE id = $1 AND state = 'running' AND run_lease = $3
414 "#,
415 )
416 .bind(job.id)
417 .bind(seconds)
418 .bind(job.run_lease)
419 .bind(&progress_snapshot)
420 .execute(pool)
421 .await?;
422 if result.rows_affected() == 0 {
423 warn!(
424 job_id = job.id,
425 "Job already rescued/cancelled, retry ignored"
426 );
427 return Ok(CompletionOutcome::IgnoredStale);
428 }
429 if needs_event {
430 let updated_job: JobRow = sqlx::query_as("SELECT * FROM awa.jobs WHERE id = $1")
431 .bind(job.id)
432 .fetch_one(pool)
433 .await?;
434 Ok(CompletionOutcome::Applied {
435 event: Some(UntypedJobEvent::Retried {
436 job: updated_job.clone(),
437 error: String::new(),
438 attempt: updated_job.attempt,
439 next_run_at: updated_job.run_at,
440 }),
441 })
442 } else {
443 Ok(CompletionOutcome::Applied { event: None })
444 }
445 }
446
447 Ok(JobResult::Snooze(snooze_duration)) => {
448 let seconds = snooze_duration.as_secs() as f64;
449 info!(
450 job_id = job.id,
451 kind = %job.kind,
452 snooze_secs = seconds,
453 "Job snoozed (attempt not incremented)"
454 );
455 let result = sqlx::query(
458 r#"
459 UPDATE awa.jobs
460 SET state = 'scheduled',
461 run_at = now() + make_interval(secs => $2),
462 attempt = attempt - 1,
463 heartbeat_at = NULL,
464 deadline_at = NULL,
465 progress = $4
466 WHERE id = $1 AND state = 'running' AND run_lease = $3
467 "#,
468 )
469 .bind(job.id)
470 .bind(seconds)
471 .bind(job.run_lease)
472 .bind(&progress_snapshot)
473 .execute(pool)
474 .await?;
475 if result.rows_affected() == 0 {
476 warn!(
477 job_id = job.id,
478 "Job already rescued/cancelled, snooze ignored"
479 );
480 return Ok(CompletionOutcome::IgnoredStale);
481 }
482 Ok(CompletionOutcome::Applied { event: None })
484 }
485
486 Ok(JobResult::Cancel(reason)) => {
487 info!(
488 job_id = job.id,
489 kind = %job.kind,
490 reason = %reason,
491 "Job cancelled by handler"
492 );
493 let result = sqlx::query(
494 r#"
495 UPDATE awa.jobs
496 SET state = 'cancelled',
497 finalized_at = now(),
498 errors = errors || $2::jsonb,
499 progress = $4
500 WHERE id = $1 AND state = 'running' AND run_lease = $3
501 "#,
502 )
503 .bind(job.id)
504 .bind(serde_json::json!({
505 "error": format!("cancelled: {}", reason),
506 "attempt": job.attempt,
507 "at": chrono::Utc::now().to_rfc3339()
508 }))
509 .bind(job.run_lease)
510 .bind(&progress_snapshot)
511 .execute(pool)
512 .await?;
513 if result.rows_affected() == 0 {
514 warn!(
515 job_id = job.id,
516 "Job already rescued/cancelled, cancel ignored"
517 );
518 return Ok(CompletionOutcome::IgnoredStale);
519 }
520 if needs_event {
521 let updated_job: JobRow = sqlx::query_as("SELECT * FROM awa.jobs WHERE id = $1")
522 .bind(job.id)
523 .fetch_one(pool)
524 .await?;
525 Ok(CompletionOutcome::Applied {
526 event: Some(UntypedJobEvent::Cancelled {
527 job: updated_job,
528 reason: reason.clone(),
529 }),
530 })
531 } else {
532 Ok(CompletionOutcome::Applied { event: None })
533 }
534 }
535
536 Ok(JobResult::WaitForCallback(_guard)) => {
537 info!(
538 job_id = job.id,
539 kind = %job.kind,
540 "Job waiting for external callback"
541 );
542 let result = sqlx::query(
545 r#"
546 UPDATE awa.jobs
547 SET state = 'waiting_external',
548 heartbeat_at = NULL,
549 deadline_at = NULL,
550 progress = $3
551 WHERE id = $1 AND state = 'running' AND run_lease = $2 AND callback_id IS NOT NULL
552 "#,
553 )
554 .bind(job.id)
555 .bind(job.run_lease)
556 .bind(&progress_snapshot)
557 .execute(pool)
558 .await?;
559 if result.rows_affected() == 0 {
560 let current: Option<(awa_model::JobState, Option<uuid::Uuid>)> =
563 sqlx::query_as("SELECT state, callback_id FROM awa.jobs WHERE id = $1")
564 .bind(job.id)
565 .fetch_optional(pool)
566 .await?;
567 match current {
568 Some((state, _)) if state.is_terminal() => {
569 info!(
571 job_id = job.id,
572 state = %state,
573 "Job already completed by racing callback"
574 );
575 return Ok(CompletionOutcome::Applied { event: None });
577 }
578 Some((_, None)) => {
579 error!(
581 job_id = job.id,
582 "WaitForCallback returned without calling register_callback"
583 );
584 sqlx::query(
585 r#"
586 UPDATE awa.jobs
587 SET state = 'failed',
588 finalized_at = now(),
589 errors = errors || $2::jsonb
590 WHERE id = $1 AND state = 'running' AND run_lease = $3
591 "#,
592 )
593 .bind(job.id)
594 .bind(serde_json::json!({
595 "error": "WaitForCallback returned without calling register_callback",
596 "attempt": job.attempt,
597 "at": chrono::Utc::now().to_rfc3339(),
598 "terminal": true
599 }))
600 .bind(job.run_lease)
601 .execute(pool)
602 .await?;
603 return Ok(CompletionOutcome::Applied { event: None });
605 }
606 _ => {
607 warn!(
608 job_id = job.id,
609 "Job already rescued/cancelled, wait-for-callback ignored"
610 );
611 return Ok(CompletionOutcome::IgnoredStale);
612 }
613 }
614 }
615 Ok(CompletionOutcome::Applied { event: None })
617 }
618
619 Err(JobError::Terminal(msg)) => {
620 tracing::Span::current().record("otel.status_code", "ERROR");
621 error!(
622 job_id = job.id,
623 kind = %job.kind,
624 error = %msg,
625 "Job failed terminally"
626 );
627 let result = sqlx::query(
628 r#"
629 UPDATE awa.jobs
630 SET state = 'failed',
631 finalized_at = now(),
632 errors = errors || $2::jsonb,
633 progress = $4
634 WHERE id = $1 AND state = 'running' AND run_lease = $3
635 "#,
636 )
637 .bind(job.id)
638 .bind(serde_json::json!({
639 "error": msg.to_string(),
640 "attempt": job.attempt,
641 "at": chrono::Utc::now().to_rfc3339(),
642 "terminal": true
643 }))
644 .bind(job.run_lease)
645 .bind(&progress_snapshot)
646 .execute(pool)
647 .await?;
648 if result.rows_affected() == 0 {
649 warn!(
650 job_id = job.id,
651 "Job already rescued/cancelled, terminal failure ignored"
652 );
653 return Ok(CompletionOutcome::IgnoredStale);
654 }
655 if needs_event {
656 let updated_job: JobRow = sqlx::query_as("SELECT * FROM awa.jobs WHERE id = $1")
657 .bind(job.id)
658 .fetch_one(pool)
659 .await?;
660 Ok(CompletionOutcome::Applied {
661 event: Some(UntypedJobEvent::Exhausted {
662 job: updated_job,
663 error: msg.clone(),
664 attempt: job.attempt,
665 }),
666 })
667 } else {
668 Ok(CompletionOutcome::Applied { event: None })
669 }
670 }
671
672 Err(JobError::Retryable(err)) => {
673 let error_msg = err.to_string();
674 if job.attempt >= job.max_attempts {
675 tracing::Span::current().record("otel.status_code", "ERROR");
676 error!(
677 job_id = job.id,
678 kind = %job.kind,
679 attempt = job.attempt,
680 max_attempts = job.max_attempts,
681 error = %error_msg,
682 "Job failed (max attempts exhausted)"
683 );
684 let result = sqlx::query(
685 r#"
686 UPDATE awa.jobs
687 SET state = 'failed',
688 finalized_at = now(),
689 errors = errors || $2::jsonb,
690 progress = $4
691 WHERE id = $1 AND state = 'running' AND run_lease = $3
692 "#,
693 )
694 .bind(job.id)
695 .bind(serde_json::json!({
696 "error": error_msg,
697 "attempt": job.attempt,
698 "at": chrono::Utc::now().to_rfc3339()
699 }))
700 .bind(job.run_lease)
701 .bind(&progress_snapshot)
702 .execute(pool)
703 .await?;
704 if result.rows_affected() == 0 {
705 warn!(
706 job_id = job.id,
707 "Job already rescued/cancelled, failure ignored"
708 );
709 return Ok(CompletionOutcome::IgnoredStale);
710 }
711 if needs_event {
712 let updated_job: JobRow =
713 sqlx::query_as("SELECT * FROM awa.jobs WHERE id = $1")
714 .bind(job.id)
715 .fetch_one(pool)
716 .await?;
717 Ok(CompletionOutcome::Applied {
718 event: Some(UntypedJobEvent::Exhausted {
719 job: updated_job,
720 error: error_msg,
721 attempt: job.attempt,
722 }),
723 })
724 } else {
725 Ok(CompletionOutcome::Applied { event: None })
726 }
727 } else {
728 warn!(
729 job_id = job.id,
730 kind = %job.kind,
731 attempt = job.attempt,
732 error = %error_msg,
733 "Job failed (will retry)"
734 );
735 let result = sqlx::query(
737 r#"
738 UPDATE awa.jobs
739 SET state = 'retryable',
740 run_at = now() + awa.backoff_duration($2, $3),
741 finalized_at = now(),
742 heartbeat_at = NULL,
743 deadline_at = NULL,
744 errors = errors || $4::jsonb,
745 progress = $6
746 WHERE id = $1 AND state = 'running' AND run_lease = $5
747 "#,
748 )
749 .bind(job.id)
750 .bind(job.attempt)
751 .bind(job.max_attempts)
752 .bind(serde_json::json!({
753 "error": error_msg,
754 "attempt": job.attempt,
755 "at": chrono::Utc::now().to_rfc3339()
756 }))
757 .bind(job.run_lease)
758 .bind(&progress_snapshot)
759 .execute(pool)
760 .await?;
761 if result.rows_affected() == 0 {
762 warn!(
763 job_id = job.id,
764 "Job already rescued/cancelled, retry ignored"
765 );
766 return Ok(CompletionOutcome::IgnoredStale);
767 }
768 if needs_event {
769 let updated_job: JobRow =
770 sqlx::query_as("SELECT * FROM awa.jobs WHERE id = $1")
771 .bind(job.id)
772 .fetch_one(pool)
773 .await?;
774 Ok(CompletionOutcome::Applied {
775 event: Some(UntypedJobEvent::Retried {
776 job: updated_job.clone(),
777 error: error_msg,
778 attempt: job.attempt,
779 next_run_at: updated_job.run_at,
780 }),
781 })
782 } else {
783 Ok(CompletionOutcome::Applied { event: None })
784 }
785 }
786 }
787 }
788}
789
790async fn dispatch_lifecycle_event(
796 handlers: &HashMap<String, Vec<BoxedUntypedEventHandler>>,
797 kind: &str,
798 event: UntypedJobEvent,
799) {
800 if let Some(handlers) = handlers.get(kind) {
801 for handler in handlers {
802 let handler = handler.clone();
803 let event = event.clone();
804 let result = tokio::spawn(async move {
805 (handler)(event).await;
806 })
807 .await;
808 if let Err(err) = result {
809 tracing::warn!(
810 kind,
811 error = %err,
812 "Lifecycle event handler panicked"
813 );
814 }
815 }
816 }
817}
818
819async fn direct_complete_job(pool: &PgPool, job: &JobRow) -> Result<bool, AwaError> {
820 let result = sqlx::query(
821 r#"
822 UPDATE awa.jobs_hot
823 SET state = 'completed',
824 finalized_at = now(),
825 progress = NULL
826 WHERE id = $1 AND state = 'running' AND run_lease = $2
827 "#,
828 )
829 .bind(job.id)
830 .bind(job.run_lease)
831 .execute(pool)
832 .await?;
833
834 Ok(result.rows_affected() > 0)
835}