1pub 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
16pub struct TestClient {
20 pool: PgPool,
21}
22
23impl TestClient {
24 pub async fn from_pool(pool: PgPool) -> Self {
26 Self { pool }
27 }
28
29 pub fn pool(&self) -> &PgPool {
31 &self.pool
32 }
33
34 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 pub async fn clean(&self) -> Result<(), AwaError> {
43 sqlx::query("DELETE FROM awa.jobs")
44 .execute(&self.pool)
45 .await?;
46 sqlx::query("DELETE FROM awa.queue_meta")
47 .execute(&self.pool)
48 .await?;
49 sqlx::query("DELETE FROM awa.runtime_storage_backends WHERE backend = 'queue_storage'")
50 .execute(&self.pool)
51 .await?;
52 Ok(())
53 }
54
55 pub async fn insert(&self, args: &impl JobArgs) -> Result<JobRow, AwaError> {
57 awa_model::insert(&self.pool, args).await
58 }
59
60 pub async fn work_one<W: Worker>(&self, worker: &W) -> Result<WorkResult, AwaError> {
65 self.work_one_in_queue(worker, None).await
66 }
67
68 pub async fn work_one_in_queue<W: Worker>(
70 &self,
71 worker: &W,
72 queue: Option<&str>,
73 ) -> Result<WorkResult, AwaError> {
74 let jobs: Vec<JobRow> = sqlx::query_as::<_, JobRow>(
76 r#"
77 WITH claimed AS (
78 SELECT id FROM awa.jobs
79 WHERE state = 'available' AND kind = $1
80 AND ($2::text IS NULL OR queue = $2)
81 ORDER BY run_at ASC, id ASC
82 LIMIT 1
83 FOR UPDATE SKIP LOCKED
84 )
85 UPDATE awa.jobs
86 SET state = 'running',
87 attempt = attempt + 1,
88 run_lease = run_lease + 1,
89 attempted_at = now(),
90 heartbeat_at = now(),
91 deadline_at = now() + interval '5 minutes'
92 FROM claimed
93 WHERE awa.jobs.id = claimed.id
94 RETURNING awa.jobs.*
95 "#,
96 )
97 .bind(worker.kind())
98 .bind(queue)
99 .fetch_all(&self.pool)
100 .await?;
101
102 let job = match jobs.into_iter().next() {
103 Some(job) => job,
104 None => return Ok(WorkResult::NoJob),
105 };
106
107 let cancel = Arc::new(AtomicBool::new(false));
108 let state: Arc<HashMap<std::any::TypeId, Box<dyn Any + Send + Sync>>> =
109 Arc::new(HashMap::new());
110 let progress = Arc::new(std::sync::Mutex::new(ProgressState::new(
111 job.progress.clone(),
112 )));
113 let ctx = JobContext::new_for_testing(
114 job.clone(),
115 cancel,
116 state,
117 self.pool.clone(),
118 progress.clone(),
119 );
120
121 let result = worker.perform(&ctx).await;
122
123 let progress_snapshot: Option<serde_json::Value> = {
125 let guard = progress.lock().expect("progress lock poisoned");
126 guard.clone_latest()
127 };
128
129 match &result {
131 Ok(JobResult::Completed) => {
132 sqlx::query(
133 "UPDATE awa.jobs SET state = 'completed', finalized_at = now(), progress = NULL WHERE id = $1",
134 )
135 .bind(job.id)
136 .execute(&self.pool)
137 .await?;
138 Ok(WorkResult::Completed(job))
139 }
140 Ok(JobResult::Cancel(reason)) => {
141 sqlx::query(
142 "UPDATE awa.jobs SET state = 'cancelled', finalized_at = now(), progress = $2 WHERE id = $1",
143 )
144 .bind(job.id)
145 .bind(&progress_snapshot)
146 .execute(&self.pool)
147 .await?;
148 Ok(WorkResult::Cancelled(job, reason.clone()))
149 }
150 Ok(JobResult::RetryAfter(_)) | Err(JobError::Retryable(_)) => {
151 sqlx::query(
152 "UPDATE awa.jobs SET state = 'retryable', finalized_at = now(), progress = $2 WHERE id = $1",
153 )
154 .bind(job.id)
155 .bind(&progress_snapshot)
156 .execute(&self.pool)
157 .await?;
158 Ok(WorkResult::Retryable(job))
159 }
160 Ok(JobResult::Snooze(_)) => {
161 sqlx::query(
162 "UPDATE awa.jobs SET state = 'available', attempt = attempt - 1, progress = $2 WHERE id = $1",
163 )
164 .bind(job.id)
165 .bind(&progress_snapshot)
166 .execute(&self.pool)
167 .await?;
168 Ok(WorkResult::Snoozed(job))
169 }
170 Ok(JobResult::WaitForCallback(_)) => {
171 let has_callback: Option<(Option<uuid::Uuid>,)> =
173 sqlx::query_as("SELECT callback_id FROM awa.jobs WHERE id = $1")
174 .bind(job.id)
175 .fetch_optional(&self.pool)
176 .await?;
177 match has_callback {
178 Some((Some(_),)) => {
179 sqlx::query(
180 "UPDATE awa.jobs SET state = 'waiting_external', heartbeat_at = NULL, deadline_at = NULL, progress = $2 WHERE id = $1",
181 )
182 .bind(job.id)
183 .bind(&progress_snapshot)
184 .execute(&self.pool)
185 .await?;
186 let updated = self.get_job(job.id).await?;
187 Ok(WorkResult::WaitingExternal(updated))
188 }
189 _ => {
190 sqlx::query(
191 "UPDATE awa.jobs SET state = 'failed', finalized_at = now() WHERE id = $1",
192 )
193 .bind(job.id)
194 .execute(&self.pool)
195 .await?;
196 Ok(WorkResult::Failed(
197 job,
198 "WaitForCallback returned without calling register_callback"
199 .to_string(),
200 ))
201 }
202 }
203 }
204 Err(JobError::Terminal(msg)) => {
205 sqlx::query(
206 "UPDATE awa.jobs SET state = 'failed', finalized_at = now(), progress = $2 WHERE id = $1",
207 )
208 .bind(job.id)
209 .bind(&progress_snapshot)
210 .execute(&self.pool)
211 .await?;
212 Ok(WorkResult::Failed(job, msg.clone()))
213 }
214 }
215 }
216
217 pub async fn get_job(&self, job_id: i64) -> Result<JobRow, AwaError> {
219 awa_model::admin::get_job(&self.pool, job_id).await
220 }
221}
222
223#[derive(Debug)]
225pub enum WorkResult {
226 NoJob,
228 Completed(JobRow),
230 Retryable(JobRow),
232 Snoozed(JobRow),
234 Cancelled(JobRow, String),
236 Failed(JobRow, String),
238 WaitingExternal(JobRow),
240}
241
242impl WorkResult {
243 pub fn is_completed(&self) -> bool {
244 matches!(self, WorkResult::Completed(_))
245 }
246
247 pub fn is_failed(&self) -> bool {
248 matches!(self, WorkResult::Failed(_, _))
249 }
250
251 pub fn is_no_job(&self) -> bool {
252 matches!(self, WorkResult::NoJob)
253 }
254
255 pub fn is_waiting_external(&self) -> bool {
256 matches!(self, WorkResult::WaitingExternal(_))
257 }
258}