Skip to main content

awa_worker/
executor.rs

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/// Result of executing a job handler.
13#[derive(Debug)]
14pub enum JobResult {
15    /// Job completed successfully.
16    Completed,
17    /// Job should be retried after the given duration. Increments attempt.
18    RetryAfter(std::time::Duration),
19    /// Job should be snoozed (re-available after duration). Does NOT increment attempt.
20    Snooze(std::time::Duration),
21    /// Job should be cancelled.
22    Cancel(String),
23    /// Job is waiting for an external callback (webhook completion).
24    /// The handler must have called `ctx.register_callback()` before returning this.
25    WaitForCallback,
26}
27
28/// Error type for job handlers — any error is retryable unless it's terminal.
29#[derive(Debug, thiserror::Error)]
30pub enum JobError {
31    /// Retryable error — will be retried if attempts remain.
32    #[error("{0}")]
33    Retryable(#[source] Box<dyn std::error::Error + Send + Sync>),
34
35    /// Terminal error — immediately fails the job regardless of remaining attempts.
36    #[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/// Worker trait — implement this for each job type.
51#[async_trait::async_trait]
52pub trait Worker: Send + Sync + 'static {
53    /// The kind string for this worker (must match the job's kind).
54    fn kind(&self) -> &'static str;
55
56    /// Execute the job. The raw args JSON and context are provided.
57    async fn perform(&self, job_row: &JobRow, ctx: &JobContext) -> Result<JobResult, JobError>;
58}
59
60/// Type-erased worker wrapper for the registry.
61pub(crate) type BoxedWorker = Box<dyn Worker>;
62
63/// Manages job execution — spawns worker futures and tracks in-flight jobs.
64pub 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    /// Build the future that executes a claimed job.
96    ///
97    /// The caller is responsible for spawning it onto the runtime.
98    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            // Seed progress from the persisted checkpoint (for retries/snoozes)
127            let progress_state = Arc::new(std::sync::Mutex::new(ProgressState::new(
128                job.progress.clone(),
129            )));
130
131            // Register as in-flight with cancel + progress
132            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            // Snapshot progress for state transition
165            let progress_snapshot = {
166                let guard = progress_state.lock().expect("progress lock poisoned");
167                guard.clone_latest()
168            };
169
170            // Complete the job based on the result, then record metrics
171            // only if the state transition actually happened (not stale).
172            match complete_job(&pool, &job, &result, &completion_batcher, progress_snapshot).await {
173                Ok(true) => {
174                    // State transition succeeded — record metrics
175                    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(_)) => {} // Not a terminal outcome
195                        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                    // Job was already rescued/cancelled — no metrics
217                }
218                Err(err) => {
219                    error!(job_id, error = %err, "Failed to complete job");
220                }
221            }
222
223            // Remove from in-flight
224            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
234/// Update job state in the database based on handler result.
235///
236/// Returns `true` if the state transition happened, `false` if the job was
237/// already rescued/cancelled by maintenance (stale completion).
238async 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            // Snooze: back to available with new run_at, decrement attempt
311            // (since it was already incremented at claim time)
312            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            // Transition to waiting_external. Requires callback_id to be set
382            // (handler must have called register_callback).
383            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                // Check if a racing callback already completed/failed the job,
400                // or if the handler forgot to call register_callback.
401                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                        // Racing callback already completed the job — all good
409                        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                        // Still running but no callback_id — programming error
418                        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                // Use database-side backoff calculation
540                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}