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