Skip to main content

awa_testing/
lib.rs

1//! Test utilities for Awa job queue.
2//!
3//! Provides `TestClient` for integration testing of job handlers.
4
5pub mod setup;
6
7use awa_model::{AwaError, JobArgs, JobRow};
8use awa_worker::context::ProgressState;
9use awa_worker::{JobContext, JobError, JobResult, Worker};
10use sqlx::PgPool;
11use std::any::Any;
12use std::collections::HashMap;
13use std::sync::atomic::AtomicBool;
14use std::sync::Arc;
15
16/// Test client for working with jobs in tests.
17///
18/// Provides helper methods for inserting jobs and executing them synchronously.
19pub struct TestClient {
20    pool: PgPool,
21}
22
23impl TestClient {
24    /// Create a test client from an existing pool.
25    pub async fn from_pool(pool: PgPool) -> Self {
26        Self { pool }
27    }
28
29    /// Get the underlying pool.
30    pub fn pool(&self) -> &PgPool {
31        &self.pool
32    }
33
34    /// Run migrations (call this in test setup).
35    pub async fn migrate(&self) -> Result<(), AwaError> {
36        awa_model::migrations::run(&self.pool).await?;
37        crate::setup::reset_runtime_backend(&self.pool).await;
38        Ok(())
39    }
40
41    /// Clean the awa schema (for test isolation).
42    pub async fn clean(&self) -> Result<(), AwaError> {
43        crate::setup::reset_runtime_backend(&self.pool).await;
44        sqlx::query("DELETE FROM awa.jobs")
45            .execute(&self.pool)
46            .await?;
47        sqlx::query("DELETE FROM awa.queue_meta")
48            .execute(&self.pool)
49            .await?;
50        Ok(())
51    }
52
53    /// Insert a job.
54    pub async fn insert(&self, args: &impl JobArgs) -> Result<JobRow, AwaError> {
55        awa_model::insert(&self.pool, args).await
56    }
57
58    /// Claim and execute a single job of type T using the given worker.
59    ///
60    /// This overload does NOT filter by queue, so it may pick up jobs from any
61    /// queue. Prefer `work_one_in_queue` for test isolation.
62    pub async fn work_one<W: Worker>(&self, worker: &W) -> Result<WorkResult, AwaError> {
63        self.work_one_in_queue(worker, None).await
64    }
65
66    /// Claim and execute a single job, optionally filtered by queue.
67    ///
68    /// Routes through the active storage engine: under queue storage it claims
69    /// and records the outcome via the runtime store API; under canonical it
70    /// drives the `awa.jobs` view directly.
71    pub async fn work_one_in_queue<W: Worker>(
72        &self,
73        worker: &W,
74        queue: Option<&str>,
75    ) -> Result<WorkResult, AwaError> {
76        if let Some(schema) =
77            awa_model::queue_storage::QueueStorage::active_schema(&self.pool).await?
78        {
79            return self.work_one_queue_storage(worker, queue, &schema).await;
80        }
81
82        // Claim one job
83        let jobs: Vec<JobRow> = sqlx::query_as::<_, JobRow>(
84            r#"
85            WITH claimed AS (
86                SELECT id FROM awa.jobs
87                WHERE state = 'available' AND kind = $1
88                  AND ($2::text IS NULL OR queue = $2)
89                ORDER BY run_at ASC, id ASC
90                LIMIT 1
91                FOR UPDATE SKIP LOCKED
92            )
93            UPDATE awa.jobs
94            SET state = 'running',
95                attempt = attempt + 1,
96                run_lease = run_lease + 1,
97                attempted_at = now(),
98                heartbeat_at = now(),
99                deadline_at = now() + interval '5 minutes'
100            FROM claimed
101            WHERE awa.jobs.id = claimed.id
102            RETURNING awa.jobs.*
103            "#,
104        )
105        .bind(worker.kind())
106        .bind(queue)
107        .fetch_all(&self.pool)
108        .await?;
109
110        let job = match jobs.into_iter().next() {
111            Some(job) => job,
112            None => return Ok(WorkResult::NoJob),
113        };
114
115        let (result, progress_snapshot) = self.run_handler(&job, worker).await;
116
117        // Update job state based on result
118        match &result {
119            Ok(JobResult::Completed) => {
120                sqlx::query(
121                    "UPDATE awa.jobs SET state = 'completed', finalized_at = now(), progress = NULL WHERE id = $1",
122                )
123                .bind(job.id)
124                .execute(&self.pool)
125                .await?;
126                Ok(WorkResult::Completed(job))
127            }
128            Ok(JobResult::Cancel(reason)) => {
129                sqlx::query(
130                    "UPDATE awa.jobs SET state = 'cancelled', finalized_at = now(), progress = $2 WHERE id = $1",
131                )
132                .bind(job.id)
133                .bind(&progress_snapshot)
134                .execute(&self.pool)
135                .await?;
136                Ok(WorkResult::Cancelled(job, reason.clone()))
137            }
138            Ok(JobResult::RetryAfter(_)) | Err(JobError::Retryable(_)) => {
139                sqlx::query(
140                    "UPDATE awa.jobs SET state = 'retryable', finalized_at = now(), progress = $2 WHERE id = $1",
141                )
142                .bind(job.id)
143                .bind(&progress_snapshot)
144                .execute(&self.pool)
145                .await?;
146                Ok(WorkResult::Retryable(job))
147            }
148            Ok(JobResult::Snooze(_)) => {
149                sqlx::query(
150                    "UPDATE awa.jobs SET state = 'available', attempt = attempt - 1, progress = $2 WHERE id = $1",
151                )
152                .bind(job.id)
153                .bind(&progress_snapshot)
154                .execute(&self.pool)
155                .await?;
156                Ok(WorkResult::Snoozed(job))
157            }
158            Ok(JobResult::WaitForCallback(_)) => {
159                // Check if callback_id was registered
160                let has_callback: Option<(Option<uuid::Uuid>,)> =
161                    sqlx::query_as("SELECT callback_id FROM awa.jobs WHERE id = $1")
162                        .bind(job.id)
163                        .fetch_optional(&self.pool)
164                        .await?;
165                match has_callback {
166                    Some((Some(_),)) => {
167                        sqlx::query(
168                            "UPDATE awa.jobs SET state = 'waiting_external', heartbeat_at = NULL, deadline_at = NULL, progress = $2 WHERE id = $1",
169                        )
170                        .bind(job.id)
171                        .bind(&progress_snapshot)
172                        .execute(&self.pool)
173                        .await?;
174                        let updated = self.get_job(job.id).await?;
175                        Ok(WorkResult::WaitingExternal(updated))
176                    }
177                    _ => {
178                        sqlx::query(
179                            "UPDATE awa.jobs SET state = 'failed', finalized_at = now() WHERE id = $1",
180                        )
181                        .bind(job.id)
182                        .execute(&self.pool)
183                        .await?;
184                        Ok(WorkResult::Failed(
185                            job,
186                            "WaitForCallback returned without calling register_callback"
187                                .to_string(),
188                        ))
189                    }
190                }
191            }
192            Err(JobError::Terminal(msg)) => {
193                sqlx::query(
194                    "UPDATE awa.jobs SET state = 'failed', finalized_at = now(), progress = $2 WHERE id = $1",
195                )
196                .bind(job.id)
197                .bind(&progress_snapshot)
198                .execute(&self.pool)
199                .await?;
200                Ok(WorkResult::Failed(job, msg.clone()))
201            }
202        }
203    }
204
205    /// Build a testing `JobContext`, run the worker, and snapshot any progress
206    /// it buffered. Shared by the canonical and queue-storage work paths.
207    async fn run_handler<W: Worker>(
208        &self,
209        job: &JobRow,
210        worker: &W,
211    ) -> (Result<JobResult, JobError>, Option<serde_json::Value>) {
212        let cancel = Arc::new(AtomicBool::new(false));
213        let state: Arc<HashMap<std::any::TypeId, Box<dyn Any + Send + Sync>>> =
214            Arc::new(HashMap::new());
215        let progress = Arc::new(std::sync::Mutex::new(ProgressState::new(
216            job.progress.clone(),
217        )));
218        let ctx = JobContext::new_for_testing(
219            job.clone(),
220            cancel,
221            state,
222            self.pool.clone(),
223            progress.clone(),
224        );
225        let result = worker.perform(&ctx).await;
226        let snapshot = {
227            let guard = progress.lock().expect("progress lock poisoned");
228            guard.clone_latest()
229        };
230        (result, snapshot)
231    }
232
233    /// Queue-storage variant of [`Self::work_one_in_queue`]: claim, run the
234    /// handler, and record the outcome through the runtime store API rather
235    /// than writing the `awa.jobs` view (which the engine rejects).
236    async fn work_one_queue_storage<W: Worker>(
237        &self,
238        worker: &W,
239        queue: Option<&str>,
240        schema: &str,
241    ) -> Result<WorkResult, AwaError> {
242        use std::time::Duration;
243        let store = awa_model::queue_storage::QueueStorage::from_existing_schema(schema)?;
244
245        // The store claims per queue; when the caller did not pin one, resolve a
246        // queue carrying a ready job of this worker's kind.
247        let target_queue = match queue {
248            Some(queue) => queue.to_string(),
249            None => {
250                let resolved: Option<String> = sqlx::query_scalar(&format!(
251                    "SELECT queue FROM {schema}.ready_entries WHERE kind = $1 ORDER BY job_id ASC LIMIT 1"
252                ))
253                .bind(worker.kind())
254                .fetch_optional(&self.pool)
255                .await?;
256                match resolved {
257                    Some(queue) => queue,
258                    None => return Ok(WorkResult::NoJob),
259                }
260            }
261        };
262
263        let claimed = store
264            .claim_runtime_batch(&self.pool, &target_queue, 1, Duration::from_secs(60))
265            .await?;
266        let claimed = match claimed.into_iter().next() {
267            Some(claimed) => claimed,
268            None => return Ok(WorkResult::NoJob),
269        };
270        let job = claimed.job.clone();
271
272        let (result, progress_snapshot) = self.run_handler(&job, worker).await;
273
274        match &result {
275            Ok(JobResult::Completed) => {
276                store
277                    .complete_runtime_batch(&self.pool, std::slice::from_ref(&claimed))
278                    .await?;
279                Ok(WorkResult::Completed(job))
280            }
281            Ok(JobResult::Cancel(reason)) => {
282                store
283                    .cancel_running(&self.pool, job.id, job.run_lease, reason, progress_snapshot)
284                    .await?;
285                Ok(WorkResult::Cancelled(job, reason.clone()))
286            }
287            Ok(JobResult::RetryAfter(delay)) => {
288                store
289                    .retry_after(&self.pool, job.id, job.run_lease, *delay, progress_snapshot)
290                    .await?;
291                Ok(WorkResult::Retryable(job))
292            }
293            Err(JobError::Retryable(_)) => {
294                // A retryable error reschedules the job. Canonical leaves it in
295                // a finalized `retryable` state (not re-claimable by a later
296                // `work_one`); the store needs an explicit delay, so use a long
297                // one rather than a near-immediate reschedule that would let the
298                // same job be re-claimed within a test or accumulate across runs.
299                store
300                    .retry_after(
301                        &self.pool,
302                        job.id,
303                        job.run_lease,
304                        Duration::from_secs(3600),
305                        progress_snapshot,
306                    )
307                    .await?;
308                Ok(WorkResult::Retryable(job))
309            }
310            Ok(JobResult::Snooze(delay)) => {
311                store
312                    .snooze(&self.pool, job.id, job.run_lease, *delay, progress_snapshot)
313                    .await?;
314                Ok(WorkResult::Snoozed(job))
315            }
316            Ok(JobResult::WaitForCallback(_)) => {
317                // The handler registers the callback via the context; park to
318                // waiting_external when it did, otherwise fail like the
319                // canonical path.
320                match self.get_job(job.id).await?.callback_id {
321                    Some(callback_id) => {
322                        let entered = store
323                            .enter_callback_wait(&self.pool, job.id, job.run_lease, callback_id)
324                            .await?;
325                        assert!(
326                            entered,
327                            "enter_callback_wait did not transition job {} to waiting_external",
328                            job.id
329                        );
330                        Ok(WorkResult::WaitingExternal(self.get_job(job.id).await?))
331                    }
332                    None => {
333                        let msg = "WaitForCallback returned without calling register_callback";
334                        store
335                            .fail_terminal(
336                                &self.pool,
337                                job.id,
338                                job.run_lease,
339                                msg,
340                                progress_snapshot,
341                            )
342                            .await?;
343                        Ok(WorkResult::Failed(job, msg.to_string()))
344                    }
345                }
346            }
347            Err(JobError::Terminal(msg)) => {
348                store
349                    .fail_terminal(&self.pool, job.id, job.run_lease, msg, progress_snapshot)
350                    .await?;
351                Ok(WorkResult::Failed(job, msg.clone()))
352            }
353        }
354    }
355
356    /// Get a job by ID.
357    pub async fn get_job(&self, job_id: i64) -> Result<JobRow, AwaError> {
358        awa_model::admin::get_job(&self.pool, job_id).await
359    }
360}
361
362/// Result of `work_one`.
363#[derive(Debug)]
364pub enum WorkResult {
365    /// No job was available.
366    NoJob,
367    /// Job completed successfully.
368    Completed(JobRow),
369    /// Job was retried.
370    Retryable(JobRow),
371    /// Job was snoozed.
372    Snoozed(JobRow),
373    /// Job was cancelled.
374    Cancelled(JobRow, String),
375    /// Job failed terminally.
376    Failed(JobRow, String),
377    /// Job is waiting for an external callback.
378    WaitingExternal(JobRow),
379}
380
381impl WorkResult {
382    pub fn is_completed(&self) -> bool {
383        matches!(self, WorkResult::Completed(_))
384    }
385
386    pub fn is_failed(&self) -> bool {
387        matches!(self, WorkResult::Failed(_, _))
388    }
389
390    pub fn is_no_job(&self) -> bool {
391        matches!(self, WorkResult::NoJob)
392    }
393
394    pub fn is_waiting_external(&self) -> bool {
395        matches!(self, WorkResult::WaitingExternal(_))
396    }
397}