1use crate::completion::CompletionBatcherHandle;
2use crate::context::JobContext;
3use crate::runtime::{InFlightMap, InFlightState, ProgressState};
4use awa_model::{AwaError, JobRow};
5use sqlx::PgPool;
6use std::any::Any;
7use std::collections::HashMap;
8use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
9use std::sync::Arc;
10use tracing::{error, info, info_span, warn, Instrument};
11
12#[derive(Debug)]
14pub enum JobResult {
15 Completed,
17 RetryAfter(std::time::Duration),
19 Snooze(std::time::Duration),
21 Cancel(String),
23 WaitForCallback,
26}
27
28#[derive(Debug, thiserror::Error)]
30pub enum JobError {
31 #[error("{0}")]
33 Retryable(#[source] Box<dyn std::error::Error + Send + Sync>),
34
35 #[error("terminal: {0}")]
37 Terminal(String),
38}
39
40impl JobError {
41 pub fn retryable(err: impl std::error::Error + Send + Sync + 'static) -> Self {
42 JobError::Retryable(Box::new(err))
43 }
44
45 pub fn terminal(msg: impl Into<String>) -> Self {
46 JobError::Terminal(msg.into())
47 }
48}
49
50#[async_trait::async_trait]
52pub trait Worker: Send + Sync + 'static {
53 fn kind(&self) -> &'static str;
55
56 async fn perform(&self, job_row: &JobRow, ctx: &JobContext) -> Result<JobResult, JobError>;
58}
59
60pub(crate) type BoxedWorker = Box<dyn Worker>;
62
63pub struct JobExecutor {
65 pool: PgPool,
66 workers: Arc<HashMap<String, BoxedWorker>>,
67 in_flight: InFlightMap,
68 queue_in_flight: Arc<HashMap<String, Arc<AtomicU32>>>,
69 state: Arc<HashMap<std::any::TypeId, Box<dyn Any + Send + Sync>>>,
70 metrics: crate::metrics::AwaMetrics,
71 completion_batcher: CompletionBatcherHandle,
72}
73
74impl JobExecutor {
75 pub(crate) fn new(
76 pool: PgPool,
77 workers: Arc<HashMap<String, BoxedWorker>>,
78 in_flight: InFlightMap,
79 queue_in_flight: Arc<HashMap<String, Arc<AtomicU32>>>,
80 state: Arc<HashMap<std::any::TypeId, Box<dyn Any + Send + Sync>>>,
81 metrics: crate::metrics::AwaMetrics,
82 completion_batcher: CompletionBatcherHandle,
83 ) -> Self {
84 Self {
85 pool,
86 workers,
87 in_flight,
88 queue_in_flight,
89 state,
90 metrics,
91 completion_batcher,
92 }
93 }
94
95 pub fn execute_task(
99 &self,
100 job: JobRow,
101 cancel: Arc<AtomicBool>,
102 ) -> impl std::future::Future<Output = ()> + Send + 'static {
103 let pool = self.pool.clone();
104 let workers = self.workers.clone();
105 let in_flight = self.in_flight.clone();
106 let queue_in_flight = self.queue_in_flight.clone();
107 let state = self.state.clone();
108 let metrics = self.metrics.clone();
109 let completion_batcher = self.completion_batcher.clone();
110 let job_id = job.id;
111 let job_run_lease = job.run_lease;
112 let job_kind = job.kind.clone();
113 let job_queue = job.queue.clone();
114
115 let span = info_span!(
116 "job.execute",
117 job.id = job_id,
118 job.kind = %job_kind,
119 job.queue = %job_queue,
120 job.attempt = job.attempt,
121 otel.name = %format!("job.execute {}", job_kind),
122 otel.status_code = tracing::field::Empty,
123 );
124
125 async move {
126 let progress_state = Arc::new(std::sync::Mutex::new(ProgressState::new(
128 job.progress.clone(),
129 )));
130
131 let in_flight_state = InFlightState {
133 cancel: cancel.clone(),
134 progress: progress_state.clone(),
135 };
136 in_flight.insert((job_id, job_run_lease), in_flight_state);
137 if let Some(counter) = queue_in_flight.get(&job_queue) {
138 counter.fetch_add(1, Ordering::SeqCst);
139 }
140 metrics.record_in_flight_change(&job_queue, 1);
141
142 let start = std::time::Instant::now();
143 let ctx = JobContext::new(
144 job.clone(),
145 cancel,
146 state,
147 pool.clone(),
148 progress_state.clone(),
149 );
150
151 let result = match workers.get(&job.kind) {
152 Some(worker) => worker.perform(&job, &ctx).await,
153 None => {
154 error!(kind = %job.kind, job_id, "No worker registered for job kind");
155 Err(JobError::Terminal(format!(
156 "unknown job kind: {}",
157 job.kind
158 )))
159 }
160 };
161
162 let duration = start.elapsed();
163
164 let progress_snapshot = {
166 let guard = progress_state.lock().expect("progress lock poisoned");
167 guard.clone_latest()
168 };
169
170 match complete_job(&pool, &job, &result, &completion_batcher, progress_snapshot).await {
173 Ok(true) => {
174 match &result {
176 Ok(JobResult::Completed) => {
177 metrics.record_job_completed(&job_kind, &job_queue, duration);
178 }
179 Ok(JobResult::RetryAfter(_)) => {
180 metrics.record_job_retried(&job_kind, &job_queue);
181 }
182 Ok(JobResult::Cancel(_)) => {
183 metrics.jobs_cancelled.add(
184 1,
185 &[
186 opentelemetry::KeyValue::new("awa.job.kind", job_kind.clone()),
187 opentelemetry::KeyValue::new(
188 "awa.job.queue",
189 job_queue.clone(),
190 ),
191 ],
192 );
193 }
194 Ok(JobResult::Snooze(_)) => {} Ok(JobResult::WaitForCallback) => {
196 metrics.jobs_waiting_external.add(
197 1,
198 &[
199 opentelemetry::KeyValue::new("awa.job.kind", job_kind.clone()),
200 opentelemetry::KeyValue::new(
201 "awa.job.queue",
202 job_queue.clone(),
203 ),
204 ],
205 );
206 }
207 Err(JobError::Terminal(_)) => {
208 metrics.record_job_failed(&job_kind, &job_queue, true);
209 }
210 Err(JobError::Retryable(_)) => {
211 metrics.record_job_retried(&job_kind, &job_queue);
212 }
213 }
214 }
215 Ok(false) => {
216 }
218 Err(err) => {
219 error!(job_id, error = %err, "Failed to complete job");
220 }
221 }
222
223 in_flight.remove((job_id, job_run_lease));
225 if let Some(counter) = queue_in_flight.get(&job_queue) {
226 counter.fetch_sub(1, Ordering::SeqCst);
227 }
228 metrics.record_in_flight_change(&job_queue, -1);
229 }
230 .instrument(span)
231 }
232}
233
234async fn complete_job(
239 pool: &PgPool,
240 job: &JobRow,
241 result: &Result<JobResult, JobError>,
242 completion_batcher: &CompletionBatcherHandle,
243 progress_snapshot: Option<serde_json::Value>,
244) -> Result<bool, AwaError> {
245 match result {
246 Ok(JobResult::Completed) => {
247 tracing::Span::current().record("otel.status_code", "OK");
248 info!(job_id = job.id, kind = %job.kind, attempt = job.attempt, "Job completed");
249 let result = match completion_batcher.complete(job.id, job.run_lease).await {
250 Ok(updated) => updated,
251 Err(err) => {
252 warn!(
253 job_id = job.id,
254 error = %err,
255 "Completion batch flush failed, falling back to direct finalize"
256 );
257 direct_complete_job(pool, job).await?
258 }
259 };
260 if !result {
261 warn!(
262 job_id = job.id,
263 "Job already rescued/cancelled, completion ignored"
264 );
265 return Ok(false);
266 }
267 }
268
269 Ok(JobResult::RetryAfter(duration)) => {
270 let seconds = duration.as_secs() as f64;
271 info!(
272 job_id = job.id,
273 kind = %job.kind,
274 retry_after_secs = seconds,
275 "Job requested retry after duration"
276 );
277 let result = sqlx::query(
278 r#"
279 UPDATE awa.jobs
280 SET state = 'retryable',
281 run_at = now() + make_interval(secs => $2),
282 finalized_at = now(),
283 progress = $4
284 WHERE id = $1 AND state = 'running' AND run_lease = $3
285 "#,
286 )
287 .bind(job.id)
288 .bind(seconds)
289 .bind(job.run_lease)
290 .bind(&progress_snapshot)
291 .execute(pool)
292 .await?;
293 if result.rows_affected() == 0 {
294 warn!(
295 job_id = job.id,
296 "Job already rescued/cancelled, retry ignored"
297 );
298 return Ok(false);
299 }
300 }
301
302 Ok(JobResult::Snooze(duration)) => {
303 let seconds = duration.as_secs() as f64;
304 info!(
305 job_id = job.id,
306 kind = %job.kind,
307 snooze_secs = seconds,
308 "Job snoozed (attempt not incremented)"
309 );
310 let result = sqlx::query(
313 r#"
314 UPDATE awa.jobs
315 SET state = 'scheduled',
316 run_at = now() + make_interval(secs => $2),
317 attempt = attempt - 1,
318 heartbeat_at = NULL,
319 deadline_at = NULL,
320 progress = $4
321 WHERE id = $1 AND state = 'running' AND run_lease = $3
322 "#,
323 )
324 .bind(job.id)
325 .bind(seconds)
326 .bind(job.run_lease)
327 .bind(&progress_snapshot)
328 .execute(pool)
329 .await?;
330 if result.rows_affected() == 0 {
331 warn!(
332 job_id = job.id,
333 "Job already rescued/cancelled, snooze ignored"
334 );
335 return Ok(false);
336 }
337 }
338
339 Ok(JobResult::Cancel(reason)) => {
340 info!(
341 job_id = job.id,
342 kind = %job.kind,
343 reason = %reason,
344 "Job cancelled by handler"
345 );
346 let result = sqlx::query(
347 r#"
348 UPDATE awa.jobs
349 SET state = 'cancelled',
350 finalized_at = now(),
351 errors = errors || $2::jsonb,
352 progress = $4
353 WHERE id = $1 AND state = 'running' AND run_lease = $3
354 "#,
355 )
356 .bind(job.id)
357 .bind(serde_json::json!({
358 "error": format!("cancelled: {}", reason),
359 "attempt": job.attempt,
360 "at": chrono::Utc::now().to_rfc3339()
361 }))
362 .bind(job.run_lease)
363 .bind(&progress_snapshot)
364 .execute(pool)
365 .await?;
366 if result.rows_affected() == 0 {
367 warn!(
368 job_id = job.id,
369 "Job already rescued/cancelled, cancel ignored"
370 );
371 return Ok(false);
372 }
373 }
374
375 Ok(JobResult::WaitForCallback) => {
376 info!(
377 job_id = job.id,
378 kind = %job.kind,
379 "Job waiting for external callback"
380 );
381 let result = sqlx::query(
384 r#"
385 UPDATE awa.jobs
386 SET state = 'waiting_external',
387 heartbeat_at = NULL,
388 deadline_at = NULL,
389 progress = $3
390 WHERE id = $1 AND state = 'running' AND run_lease = $2 AND callback_id IS NOT NULL
391 "#,
392 )
393 .bind(job.id)
394 .bind(job.run_lease)
395 .bind(&progress_snapshot)
396 .execute(pool)
397 .await?;
398 if result.rows_affected() == 0 {
399 let current: Option<(awa_model::JobState, Option<uuid::Uuid>)> =
402 sqlx::query_as("SELECT state, callback_id FROM awa.jobs WHERE id = $1")
403 .bind(job.id)
404 .fetch_optional(pool)
405 .await?;
406 match current {
407 Some((state, _)) if state.is_terminal() => {
408 info!(
410 job_id = job.id,
411 state = %state,
412 "Job already completed by racing callback"
413 );
414 return Ok(true);
415 }
416 Some((_, None)) => {
417 error!(
419 job_id = job.id,
420 "WaitForCallback returned without calling register_callback"
421 );
422 sqlx::query(
423 r#"
424 UPDATE awa.jobs
425 SET state = 'failed',
426 finalized_at = now(),
427 errors = errors || $2::jsonb
428 WHERE id = $1 AND state = 'running' AND run_lease = $3
429 "#,
430 )
431 .bind(job.id)
432 .bind(serde_json::json!({
433 "error": "WaitForCallback returned without calling register_callback",
434 "attempt": job.attempt,
435 "at": chrono::Utc::now().to_rfc3339(),
436 "terminal": true
437 }))
438 .bind(job.run_lease)
439 .execute(pool)
440 .await?;
441 return Ok(true);
442 }
443 _ => {
444 warn!(
445 job_id = job.id,
446 "Job already rescued/cancelled, wait-for-callback ignored"
447 );
448 return Ok(false);
449 }
450 }
451 }
452 }
453
454 Err(JobError::Terminal(msg)) => {
455 tracing::Span::current().record("otel.status_code", "ERROR");
456 error!(
457 job_id = job.id,
458 kind = %job.kind,
459 error = %msg,
460 "Job failed terminally"
461 );
462 let result = sqlx::query(
463 r#"
464 UPDATE awa.jobs
465 SET state = 'failed',
466 finalized_at = now(),
467 errors = errors || $2::jsonb,
468 progress = $4
469 WHERE id = $1 AND state = 'running' AND run_lease = $3
470 "#,
471 )
472 .bind(job.id)
473 .bind(serde_json::json!({
474 "error": msg.to_string(),
475 "attempt": job.attempt,
476 "at": chrono::Utc::now().to_rfc3339(),
477 "terminal": true
478 }))
479 .bind(job.run_lease)
480 .bind(&progress_snapshot)
481 .execute(pool)
482 .await?;
483 if result.rows_affected() == 0 {
484 warn!(
485 job_id = job.id,
486 "Job already rescued/cancelled, terminal failure ignored"
487 );
488 return Ok(false);
489 }
490 }
491
492 Err(JobError::Retryable(err)) => {
493 let error_msg = err.to_string();
494 if job.attempt >= job.max_attempts {
495 tracing::Span::current().record("otel.status_code", "ERROR");
496 error!(
497 job_id = job.id,
498 kind = %job.kind,
499 attempt = job.attempt,
500 max_attempts = job.max_attempts,
501 error = %error_msg,
502 "Job failed (max attempts exhausted)"
503 );
504 let result = sqlx::query(
505 r#"
506 UPDATE awa.jobs
507 SET state = 'failed',
508 finalized_at = now(),
509 errors = errors || $2::jsonb,
510 progress = $4
511 WHERE id = $1 AND state = 'running' AND run_lease = $3
512 "#,
513 )
514 .bind(job.id)
515 .bind(serde_json::json!({
516 "error": error_msg,
517 "attempt": job.attempt,
518 "at": chrono::Utc::now().to_rfc3339()
519 }))
520 .bind(job.run_lease)
521 .bind(&progress_snapshot)
522 .execute(pool)
523 .await?;
524 if result.rows_affected() == 0 {
525 warn!(
526 job_id = job.id,
527 "Job already rescued/cancelled, failure ignored"
528 );
529 return Ok(false);
530 }
531 } else {
532 warn!(
533 job_id = job.id,
534 kind = %job.kind,
535 attempt = job.attempt,
536 error = %error_msg,
537 "Job failed (will retry)"
538 );
539 let result = sqlx::query(
541 r#"
542 UPDATE awa.jobs
543 SET state = 'retryable',
544 run_at = now() + awa.backoff_duration($2, $3),
545 finalized_at = now(),
546 heartbeat_at = NULL,
547 deadline_at = NULL,
548 errors = errors || $4::jsonb,
549 progress = $6
550 WHERE id = $1 AND state = 'running' AND run_lease = $5
551 "#,
552 )
553 .bind(job.id)
554 .bind(job.attempt)
555 .bind(job.max_attempts)
556 .bind(serde_json::json!({
557 "error": error_msg,
558 "attempt": job.attempt,
559 "at": chrono::Utc::now().to_rfc3339()
560 }))
561 .bind(job.run_lease)
562 .bind(&progress_snapshot)
563 .execute(pool)
564 .await?;
565 if result.rows_affected() == 0 {
566 warn!(
567 job_id = job.id,
568 "Job already rescued/cancelled, retry ignored"
569 );
570 return Ok(false);
571 }
572 }
573 }
574 }
575
576 Ok(true)
577}
578
579async fn direct_complete_job(pool: &PgPool, job: &JobRow) -> Result<bool, AwaError> {
580 let result = sqlx::query(
581 r#"
582 UPDATE awa.jobs_hot
583 SET state = 'completed',
584 finalized_at = now(),
585 progress = NULL
586 WHERE id = $1 AND state = 'running' AND run_lease = $2
587 "#,
588 )
589 .bind(job.id)
590 .bind(job.run_lease)
591 .execute(pool)
592 .await?;
593
594 Ok(result.rows_affected() > 0)
595}