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 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 pub async fn insert(&self, args: &impl JobArgs) -> Result<JobRow, AwaError> {
55 awa_model::insert(&self.pool, args).await
56 }
57
58 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 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 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 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 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 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 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 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 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 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 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#[derive(Debug)]
364pub enum WorkResult {
365 NoJob,
367 Completed(JobRow),
369 Retryable(JobRow),
371 Snoozed(JobRow),
373 Cancelled(JobRow, String),
375 Failed(JobRow, String),
377 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}