1use crate::{
6 async_job::{
7 AsyncJob, AsyncJobProvider, AsyncJobStatus, CreateAsyncJob, JobLease, JobResult,
8 LeaseRenewal,
9 },
10 pg::{PGConnection, StoreError},
11 utilities::generate_id,
12};
13use haste_fhir_operation_error::OperationOutcomeError;
14use haste_jwt::{ProjectId, TenantId};
15use sqlx::{PgExecutor, types::Json};
16use std::time::Duration;
17
18async fn create_job<'e, E>(
19 executor: E,
20 tenant: &TenantId,
21 project: &ProjectId,
22 job: CreateAsyncJob,
23) -> Result<AsyncJob, OperationOutcomeError>
24where
25 E: PgExecutor<'e>,
26{
27 let submitted_by = job.claims.sub.as_ref().to_string();
28
29 let created = sqlx::query_as::<_, AsyncJob>(
30 r"
31 INSERT INTO async_jobs
32 (id, tenant, project, kind, request, parameters, claims, submitted_by,
33 max_attempts, expires_at)
34 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, now() + make_interval(secs => $10))
35 RETURNING *
36 ",
37 )
38 .bind(generate_id(None))
39 .bind(tenant.as_ref())
40 .bind(project.as_ref())
41 .bind(&job.kind)
42 .bind(&job.request)
43 .bind(Json(&job.parameters))
44 .bind(Json(&job.claims))
45 .bind(submitted_by)
46 .bind(job.max_attempts)
47 .bind(job.retention.as_secs_f64())
48 .fetch_one(executor)
49 .await
50 .map_err(StoreError::from)?;
51
52 Ok(created)
53}
54
55async fn read_job<'e, E>(
56 executor: E,
57 tenant: &TenantId,
58 project: &ProjectId,
59 id: &str,
60) -> Result<Option<AsyncJob>, OperationOutcomeError>
61where
62 E: PgExecutor<'e>,
63{
64 let job = sqlx::query_as::<_, AsyncJob>(
65 "SELECT * FROM async_jobs WHERE tenant = $1 AND project = $2 AND id = $3",
66 )
67 .bind(tenant.as_ref())
68 .bind(project.as_ref())
69 .bind(id)
70 .fetch_optional(executor)
71 .await
72 .map_err(StoreError::from)?;
73
74 Ok(job)
75}
76
77async fn count_live_jobs<'e, E>(
78 executor: E,
79 tenant: &TenantId,
80 project: &ProjectId,
81) -> Result<i64, OperationOutcomeError>
82where
83 E: PgExecutor<'e>,
84{
85 let count: i64 = sqlx::query_scalar(
86 r"
87 SELECT count(*) FROM async_jobs
88 WHERE tenant = $1 AND project = $2 AND status IN ('accepted', 'in-progress')
89 ",
90 )
91 .bind(tenant.as_ref())
92 .bind(project.as_ref())
93 .fetch_one(executor)
94 .await
95 .map_err(StoreError::from)?;
96
97 Ok(count)
98}
99
100async fn cancel_job<'e, E>(
103 executor: E,
104 tenant: &TenantId,
105 project: &ProjectId,
106 id: &str,
107) -> Result<Option<AsyncJob>, OperationOutcomeError>
108where
109 E: PgExecutor<'e>,
110{
111 let job = sqlx::query_as::<_, AsyncJob>(
112 r"
113 UPDATE async_jobs
114 SET status = 'cancelled',
115 finished_at = COALESCE(finished_at, now()),
116 locked_by = NULL,
117 lease_expires_at = NULL,
118 expires_at = now()
119 WHERE tenant = $1 AND project = $2 AND id = $3 AND status <> 'cancelled'
120 RETURNING *
121 ",
122 )
123 .bind(tenant.as_ref())
124 .bind(project.as_ref())
125 .bind(id)
126 .fetch_optional(executor)
127 .await
128 .map_err(StoreError::from)?;
129
130 Ok(job)
131}
132
133async fn claim_job<'e, E>(
137 executor: E,
138 kinds: &[&str],
139 lease: &JobLease,
140) -> Result<Option<AsyncJob>, OperationOutcomeError>
141where
142 E: PgExecutor<'e>,
143{
144 let kinds: Vec<String> = kinds.iter().map(ToString::to_string).collect();
145
146 let job = sqlx::query_as::<_, AsyncJob>(
147 r"
148 UPDATE async_jobs
149 SET status = 'in-progress',
150 locked_by = $1,
151 lease_expires_at = now() + make_interval(secs => $2),
152 attempts = attempts + 1,
153 started_at = COALESCE(started_at, now()),
154 progress = NULL
155 WHERE id = (
156 SELECT id FROM async_jobs
157 WHERE kind = ANY($3)
158 AND attempts < max_attempts
159 AND (status = 'accepted'
160 OR (status = 'in-progress' AND lease_expires_at < now()))
161 ORDER BY created_at
162 LIMIT 1
163 FOR NO KEY UPDATE SKIP LOCKED
164 )
165 RETURNING *
166 ",
167 )
168 .bind(&lease.worker)
169 .bind(lease.duration.as_secs_f64())
170 .bind(&kinds)
171 .fetch_optional(executor)
172 .await
173 .map_err(StoreError::from)?;
174
175 Ok(job)
176}
177
178async fn renew_lease<'e, E>(
179 executor: E,
180 id: &str,
181 lease: &JobLease,
182 progress: Option<&str>,
183) -> Result<LeaseRenewal, OperationOutcomeError>
184where
185 E: PgExecutor<'e>,
186{
187 let result = sqlx::query(
188 r"
189 UPDATE async_jobs
190 SET lease_expires_at = now() + make_interval(secs => $2),
191 progress = COALESCE($3, progress)
192 WHERE id = $1 AND locked_by = $4 AND status = 'in-progress'
193 ",
194 )
195 .bind(id)
196 .bind(lease.duration.as_secs_f64())
197 .bind(progress)
198 .bind(&lease.worker)
199 .execute(executor)
200 .await
201 .map_err(StoreError::from)?;
202
203 Ok(if result.rows_affected() == 1 {
204 LeaseRenewal::Renewed
205 } else {
206 LeaseRenewal::Lost
207 })
208}
209
210async fn finish_job<'e, E>(
211 executor: E,
212 id: &str,
213 worker: &str,
214 result: JobResult,
215 retention: Duration,
216) -> Result<bool, OperationOutcomeError>
217where
218 E: PgExecutor<'e>,
219{
220 let (status, output, content_type, error) = match result {
221 JobResult::Completed {
222 output,
223 content_type,
224 } => (
225 AsyncJobStatus::Completed,
226 Some(Json(output)),
227 Some(content_type),
228 None,
229 ),
230 JobResult::Failed { outcome } => (AsyncJobStatus::Failed, None, None, Some(Json(outcome))),
231 };
232
233 let result = sqlx::query(
234 r"
235 UPDATE async_jobs
236 SET status = $2,
237 output = $3,
238 output_content_type = $4,
239 error = $5,
240 finished_at = now(),
241 locked_by = NULL,
242 lease_expires_at = NULL,
243 progress = NULL,
244 expires_at = now() + make_interval(secs => $6)
245 WHERE id = $1 AND locked_by = $7 AND status = 'in-progress'
246 ",
247 )
248 .bind(id)
249 .bind(status)
250 .bind(output)
251 .bind(content_type)
252 .bind(error)
253 .bind(retention.as_secs_f64())
254 .bind(worker)
255 .execute(executor)
256 .await
257 .map_err(StoreError::from)?;
258
259 Ok(result.rows_affected() == 1)
260}
261
262async fn fail_abandoned_jobs<'e, E>(
263 executor: E,
264 outcome: &serde_json::Value,
265 retention: Duration,
266) -> Result<u64, OperationOutcomeError>
267where
268 E: PgExecutor<'e>,
269{
270 let result = sqlx::query(
271 r"
272 UPDATE async_jobs
273 SET status = 'failed',
274 error = $1,
275 finished_at = now(),
276 locked_by = NULL,
277 lease_expires_at = NULL,
278 progress = NULL,
279 expires_at = now() + make_interval(secs => $2)
280 WHERE status = 'in-progress'
281 AND lease_expires_at < now()
282 AND attempts >= max_attempts
283 ",
284 )
285 .bind(Json(outcome))
286 .bind(retention.as_secs_f64())
287 .execute(executor)
288 .await
289 .map_err(StoreError::from)?;
290
291 Ok(result.rows_affected())
292}
293
294async fn delete_expired_jobs<'e, E>(
297 executor: E,
298 limit: i64,
299) -> Result<Vec<AsyncJob>, OperationOutcomeError>
300where
301 E: PgExecutor<'e>,
302{
303 let jobs = sqlx::query_as::<_, AsyncJob>(
304 r"
305 DELETE FROM async_jobs
306 WHERE id IN (
307 SELECT id FROM async_jobs
308 WHERE expires_at < now()
309 ORDER BY expires_at
310 LIMIT $1
311 FOR UPDATE SKIP LOCKED
312 )
313 RETURNING *
314 ",
315 )
316 .bind(limit)
317 .fetch_all(executor)
318 .await
319 .map_err(StoreError::from)?;
320
321 Ok(jobs)
322}
323
324macro_rules! with_executor {
328 ($self:expr, |$executor:ident| $call:expr) => {
329 match $self {
330 PGConnection::Pool(pool, _) => {
331 let $executor = pool;
332 $call.await
333 }
334 PGConnection::Transaction(tx, _, _) => {
335 let mut tx = tx.lock().await;
336 let $executor = &mut **tx;
337 $call.await
338 }
339 }
340 };
341}
342
343impl AsyncJobProvider for PGConnection {
344 async fn create_job(
345 &self,
346 tenant: &TenantId,
347 project: &ProjectId,
348 job: CreateAsyncJob,
349 ) -> Result<AsyncJob, OperationOutcomeError> {
350 with_executor!(self, |executor| create_job(executor, tenant, project, job))
351 }
352
353 async fn read_job(
354 &self,
355 tenant: &TenantId,
356 project: &ProjectId,
357 id: &str,
358 ) -> Result<Option<AsyncJob>, OperationOutcomeError> {
359 with_executor!(self, |executor| read_job(executor, tenant, project, id))
360 }
361
362 async fn count_live_jobs(
363 &self,
364 tenant: &TenantId,
365 project: &ProjectId,
366 ) -> Result<i64, OperationOutcomeError> {
367 with_executor!(self, |executor| count_live_jobs(executor, tenant, project))
368 }
369
370 async fn cancel_job(
371 &self,
372 tenant: &TenantId,
373 project: &ProjectId,
374 id: &str,
375 ) -> Result<Option<AsyncJob>, OperationOutcomeError> {
376 with_executor!(self, |executor| cancel_job(executor, tenant, project, id))
377 }
378
379 async fn claim_job(
380 &self,
381 kinds: &[&str],
382 lease: &JobLease,
383 ) -> Result<Option<AsyncJob>, OperationOutcomeError> {
384 with_executor!(self, |executor| claim_job(executor, kinds, lease))
385 }
386
387 async fn renew_lease(
388 &self,
389 id: &str,
390 lease: &JobLease,
391 progress: Option<&str>,
392 ) -> Result<LeaseRenewal, OperationOutcomeError> {
393 with_executor!(self, |executor| renew_lease(executor, id, lease, progress))
394 }
395
396 async fn finish_job(
397 &self,
398 id: &str,
399 worker: &str,
400 result: JobResult,
401 retention: Duration,
402 ) -> Result<bool, OperationOutcomeError> {
403 with_executor!(self, |executor| finish_job(
404 executor, id, worker, result, retention
405 ))
406 }
407
408 async fn fail_abandoned_jobs(
409 &self,
410 outcome: &serde_json::Value,
411 retention: Duration,
412 ) -> Result<u64, OperationOutcomeError> {
413 with_executor!(self, |executor| fail_abandoned_jobs(
414 executor, outcome, retention
415 ))
416 }
417
418 async fn delete_expired_jobs(
419 &self,
420 limit: i64,
421 ) -> Result<Vec<AsyncJob>, OperationOutcomeError> {
422 with_executor!(self, |executor| delete_expired_jobs(executor, limit))
423 }
424}