Skip to main content

haste_repository/pg/
async_job.rs

1//! Postgres storage for [`AsyncJobProvider`]. Every state change is one
2//! statement guarded by the state it expects (`status`, `locked_by`), so two
3//! workers, or a worker and a cancelling client, can never both win.
4
5use 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
100/// Cancelling also expires the job at once, so the next sweep removes it
101/// (and whatever its handler produced) instead of waiting out the retention.
102async 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
133/// One statement: the inner select picks and row-locks the job, skipping any
134/// another worker is claiming at the same instant, and the update takes it.
135/// A retry of an abandoned job starts over, so stale progress is cleared.
136async 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
294/// Bounded and lock-skipping, so several workers sweeping at once each take
295/// their own slice and none waits on another.
296async 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
324/// Runs `$call` against whichever connection this is. A job statement is a
325/// unit on its own, so none of them needs the caller's transaction, but
326/// inside one they take part in it like everything else.
327macro_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}