Skip to main content

mas_storage_pg/queue/
job.rs

1// Copyright 2025, 2026 Element Creations Ltd.
2// Copyright 2024, 2025 New Vector Ltd.
3//
4// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5// Please see LICENSE files in the repository root for full details.
6
7//! A module containing the PostgreSQL implementation of the
8//! [`QueueJobRepository`].
9
10use async_trait::async_trait;
11use chrono::{DateTime, Duration, Utc};
12use mas_data_model::{Clock, UlidExt as _};
13use mas_storage::queue::{Job, QueueJobRepository, Worker};
14use opentelemetry_semantic_conventions::trace::DB_QUERY_TEXT;
15use rand::Rng;
16use sqlx::PgConnection;
17use tracing::Instrument;
18use ulid::Ulid;
19use uuid::Uuid;
20
21use crate::{DatabaseError, DatabaseInconsistencyError, ExecuteExt};
22
23/// An implementation of [`QueueJobRepository`] for a PostgreSQL connection.
24pub struct PgQueueJobRepository<'c> {
25    conn: &'c mut PgConnection,
26}
27
28impl<'c> PgQueueJobRepository<'c> {
29    /// Create a new [`PgQueueJobRepository`] from an active PostgreSQL
30    /// connection.
31    #[must_use]
32    pub fn new(conn: &'c mut PgConnection) -> Self {
33        Self { conn }
34    }
35}
36
37struct JobReservationResult {
38    queue_job_id: Uuid,
39    queue_name: String,
40    payload: serde_json::Value,
41    metadata: serde_json::Value,
42    attempt: i32,
43}
44
45impl TryFrom<JobReservationResult> for Job {
46    type Error = DatabaseInconsistencyError;
47
48    fn try_from(value: JobReservationResult) -> Result<Self, Self::Error> {
49        let id = value.queue_job_id.into();
50        let queue_name = value.queue_name;
51        let payload = value.payload;
52
53        let metadata = serde_json::from_value(value.metadata).map_err(|e| {
54            DatabaseInconsistencyError::on("queue_jobs")
55                .column("metadata")
56                .row(id)
57                .source(e)
58        })?;
59
60        let attempt = value.attempt.try_into().map_err(|e| {
61            DatabaseInconsistencyError::on("queue_jobs")
62                .column("attempt")
63                .row(id)
64                .source(e)
65        })?;
66
67        Ok(Self {
68            id,
69            queue_name,
70            payload,
71            metadata,
72            attempt,
73        })
74    }
75}
76
77#[async_trait]
78impl QueueJobRepository for PgQueueJobRepository<'_> {
79    type Error = DatabaseError;
80
81    #[tracing::instrument(
82        name = "db.queue_job.schedule",
83        fields(
84            queue_job.id,
85            queue_job.queue_name = queue_name,
86            db.query.text,
87        ),
88        skip_all,
89    )]
90    async fn schedule(
91        &mut self,
92        rng: &mut (dyn Rng + Send),
93        clock: &dyn Clock,
94        queue_name: &str,
95        payload: serde_json::Value,
96        metadata: serde_json::Value,
97    ) -> Result<(), Self::Error> {
98        let created_at = clock.now();
99        let id = Ulid::from_datetime_with_rng(created_at, rng);
100        tracing::Span::current().record("queue_job.id", tracing::field::display(id));
101
102        sqlx::query!(
103            r#"
104                INSERT INTO queue_jobs
105                    (queue_job_id, queue_name, payload, metadata, created_at)
106                VALUES ($1, $2, $3, $4, $5)
107            "#,
108            Uuid::from(id),
109            queue_name,
110            payload,
111            metadata,
112            created_at,
113        )
114        .traced()
115        .execute(&mut *self.conn)
116        .await?;
117
118        Ok(())
119    }
120
121    #[tracing::instrument(
122        name = "db.queue_job.schedule_later",
123        fields(
124            queue_job.id,
125            queue_job.queue_name = queue_name,
126            queue_job.scheduled_at = %scheduled_at,
127            db.query.text,
128        ),
129        skip_all,
130    )]
131    async fn schedule_later(
132        &mut self,
133        rng: &mut (dyn Rng + Send),
134        clock: &dyn Clock,
135        queue_name: &str,
136        payload: serde_json::Value,
137        metadata: serde_json::Value,
138        scheduled_at: DateTime<Utc>,
139        schedule_name: Option<&str>,
140    ) -> Result<(), Self::Error> {
141        let created_at = clock.now();
142        let id = Ulid::from_datetime_with_rng(created_at, rng);
143        tracing::Span::current().record("queue_job.id", tracing::field::display(id));
144
145        sqlx::query!(
146            r#"
147                INSERT INTO queue_jobs
148                    (queue_job_id, queue_name, payload, metadata, created_at, scheduled_at, schedule_name, status)
149                VALUES ($1, $2, $3, $4, $5, $6, $7, 'scheduled')
150            "#,
151            Uuid::from(id),
152            queue_name,
153            payload,
154            metadata,
155            created_at,
156            scheduled_at,
157            schedule_name,
158        )
159        .traced()
160        .execute(&mut *self.conn)
161        .await?;
162
163        // If there was a schedule name supplied, update the queue_schedules table
164        if let Some(schedule_name) = schedule_name {
165            let span = tracing::info_span!(
166                "db.queue_job.schedule_later.update_schedules",
167                { DB_QUERY_TEXT } = tracing::field::Empty,
168            );
169
170            let res = sqlx::query!(
171                r#"
172                    UPDATE queue_schedules
173                    SET last_scheduled_at = $1,
174                        last_scheduled_job_id = $2
175                    WHERE schedule_name = $3
176                "#,
177                scheduled_at,
178                Uuid::from(id),
179                schedule_name,
180            )
181            .record(&span)
182            .execute(&mut *self.conn)
183            .instrument(span)
184            .await?;
185
186            DatabaseError::ensure_affected_rows(&res, 1)?;
187        }
188
189        Ok(())
190    }
191
192    #[tracing::instrument(
193        name = "db.queue_job.reserve",
194        skip_all,
195        fields(
196            db.query.text,
197        ),
198    )]
199    async fn reserve(
200        &mut self,
201        clock: &dyn Clock,
202        worker: &Worker,
203        queues: &[&str],
204        count: usize,
205    ) -> Result<Vec<Job>, Self::Error> {
206        let now = clock.now();
207        let max_count = i64::try_from(count).unwrap_or(i64::MAX);
208        let queues: Vec<String> = queues.iter().map(|&s| s.to_owned()).collect();
209        let results = sqlx::query_as!(
210            JobReservationResult,
211            r#"
212                -- We first grab a few jobs that are available,
213                -- using a FOR UPDATE SKIP LOCKED so that this can be run concurrently
214                -- and we don't get multiple workers grabbing the same jobs
215                WITH locked_jobs AS (
216                    SELECT queue_job_id
217                    FROM queue_jobs
218                    WHERE
219                        status = 'available'
220                        AND queue_name = ANY($1)
221                    ORDER BY queue_job_id ASC
222                    LIMIT $2
223                    FOR UPDATE
224                    SKIP LOCKED
225                )
226                -- then we update the status of those jobs to 'running', returning the job details
227                UPDATE queue_jobs
228                SET status = 'running', started_at = $3, started_by = $4
229                FROM locked_jobs
230                WHERE queue_jobs.queue_job_id = locked_jobs.queue_job_id
231                RETURNING
232                    queue_jobs.queue_job_id,
233                    queue_jobs.queue_name,
234                    queue_jobs.payload,
235                    queue_jobs.metadata,
236                    queue_jobs.attempt
237            "#,
238            &queues,
239            max_count,
240            now,
241            Uuid::from(worker.id),
242        )
243        .traced()
244        .fetch_all(&mut *self.conn)
245        .await?;
246
247        let jobs = results
248            .into_iter()
249            .map(TryFrom::try_from)
250            .collect::<Result<Vec<_>, _>>()?;
251
252        Ok(jobs)
253    }
254
255    #[tracing::instrument(
256        name = "db.queue_job.mark_as_completed",
257        skip_all,
258        fields(
259            db.query.text,
260            job.id = %id,
261        ),
262    )]
263    async fn mark_as_completed(&mut self, clock: &dyn Clock, id: Ulid) -> Result<(), Self::Error> {
264        let now = clock.now();
265        let res = sqlx::query!(
266            r#"
267                UPDATE queue_jobs
268                SET status = 'completed', completed_at = $1
269                WHERE queue_job_id = $2 AND status = 'running'
270            "#,
271            now,
272            Uuid::from(id),
273        )
274        .traced()
275        .execute(&mut *self.conn)
276        .await?;
277
278        DatabaseError::ensure_affected_rows(&res, 1)?;
279
280        Ok(())
281    }
282
283    #[tracing::instrument(
284        name = "db.queue_job.mark_as_failed",
285        skip_all,
286        fields(
287            db.query.text,
288            job.id = %id,
289        ),
290    )]
291    async fn mark_as_failed(
292        &mut self,
293        clock: &dyn Clock,
294        id: Ulid,
295        reason: &str,
296    ) -> Result<(), Self::Error> {
297        let now = clock.now();
298        let res = sqlx::query!(
299            r#"
300                UPDATE queue_jobs
301                SET
302                    status = 'failed',
303                    failed_at = $1,
304                    failed_reason = $2
305                WHERE
306                    queue_job_id = $3
307                    AND status = 'running'
308            "#,
309            now,
310            reason,
311            Uuid::from(id),
312        )
313        .traced()
314        .execute(&mut *self.conn)
315        .await?;
316
317        DatabaseError::ensure_affected_rows(&res, 1)?;
318
319        Ok(())
320    }
321
322    #[tracing::instrument(
323        name = "db.queue_job.retry",
324        skip_all,
325        fields(
326            db.query.text,
327            job.id = %id,
328        ),
329    )]
330    async fn retry(
331        &mut self,
332        rng: &mut (dyn Rng + Send),
333        clock: &dyn Clock,
334        id: Ulid,
335        delay: Duration,
336    ) -> Result<(), Self::Error> {
337        let now = clock.now();
338        let scheduled_at = now + delay;
339        let new_id = Ulid::from_datetime_with_rng(now, rng);
340
341        let span = tracing::info_span!(
342            "db.queue_job.retry.insert_job",
343            { DB_QUERY_TEXT } = tracing::field::Empty
344        );
345        // Create a new job with the same payload and metadata, but a new ID and
346        // increment the attempt
347        // We make sure we do this only for 'failed' jobs
348        let res = sqlx::query!(
349            r#"
350                INSERT INTO queue_jobs
351                    (queue_job_id, queue_name, payload, metadata, created_at,
352                     attempt, scheduled_at, schedule_name, status)
353                SELECT $1, queue_name, payload, metadata, $2, attempt + 1, $3, schedule_name, 'scheduled'
354                FROM queue_jobs
355                WHERE queue_job_id = $4
356                  AND status = 'failed'
357            "#,
358            Uuid::from(new_id),
359            now,
360            scheduled_at,
361            Uuid::from(id),
362        )
363        .record(&span)
364        .execute(&mut *self.conn)
365        .instrument(span)
366        .await?;
367
368        DatabaseError::ensure_affected_rows(&res, 1)?;
369
370        // If that job was referenced by a schedule, update the schedule
371        let span = tracing::info_span!(
372            "db.queue_job.retry.update_schedule",
373            { DB_QUERY_TEXT } = tracing::field::Empty
374        );
375        sqlx::query!(
376            r#"
377                UPDATE queue_schedules
378                SET last_scheduled_at = $1,
379                    last_scheduled_job_id = $2
380                WHERE last_scheduled_job_id = $3
381            "#,
382            scheduled_at,
383            Uuid::from(new_id),
384            Uuid::from(id),
385        )
386        .record(&span)
387        .execute(&mut *self.conn)
388        .instrument(span)
389        .await?;
390
391        // Update the old job to point to the new attempt
392        let span = tracing::info_span!(
393            "db.queue_job.retry.update_old_job",
394            { DB_QUERY_TEXT } = tracing::field::Empty
395        );
396        let res = sqlx::query!(
397            r#"
398                UPDATE queue_jobs
399                SET next_attempt_id = $1
400                WHERE queue_job_id = $2
401            "#,
402            Uuid::from(new_id),
403            Uuid::from(id),
404        )
405        .record(&span)
406        .execute(&mut *self.conn)
407        .instrument(span)
408        .await?;
409
410        DatabaseError::ensure_affected_rows(&res, 1)?;
411
412        Ok(())
413    }
414
415    #[tracing::instrument(
416        name = "db.queue_job.schedule_available_jobs",
417        skip_all,
418        fields(
419            db.query.text,
420        ),
421    )]
422    async fn schedule_available_jobs(&mut self, clock: &dyn Clock) -> Result<usize, Self::Error> {
423        let now = clock.now();
424        let res = sqlx::query!(
425            r#"
426                UPDATE queue_jobs
427                SET status = 'available'
428                WHERE
429                    status = 'scheduled'
430                    AND scheduled_at <= $1
431            "#,
432            now,
433        )
434        .traced()
435        .execute(&mut *self.conn)
436        .await?;
437
438        let count = res.rows_affected();
439        Ok(usize::try_from(count).unwrap_or(usize::MAX))
440    }
441
442    #[tracing::instrument(
443        name = "db.queue_job.cleanup",
444        skip_all,
445        fields(
446            db.query.text,
447            since = since.map(tracing::field::display),
448            until = %until,
449            limit = limit,
450        ),
451    )]
452    async fn cleanup(
453        &mut self,
454        since: Option<Ulid>,
455        until: Ulid,
456        limit: usize,
457    ) -> Result<(usize, Option<Ulid>), Self::Error> {
458        // Use ULID cursor-based pagination for completed and failed jobs.
459        // We delete both completed and failed jobs in the same batch.
460        // `MAX(uuid)` isn't a thing in Postgres, so we aggregate on the client side.
461        let res = sqlx::query_scalar!(
462            r#"
463                WITH to_delete AS (
464                    SELECT queue_job_id
465                    FROM queue_jobs
466                    WHERE (status = 'completed' OR status = 'failed')
467                      AND ($1::uuid IS NULL OR queue_job_id > $1)
468                      AND queue_job_id <= $2
469                    ORDER BY queue_job_id
470                    LIMIT $3
471                )
472                DELETE FROM queue_jobs
473                USING to_delete
474                WHERE queue_jobs.queue_job_id = to_delete.queue_job_id
475                RETURNING queue_jobs.queue_job_id
476            "#,
477            since.map(Uuid::from),
478            Uuid::from(until),
479            i64::try_from(limit).unwrap_or(i64::MAX)
480        )
481        .traced()
482        .fetch_all(&mut *self.conn)
483        .await?;
484
485        let count = res.len();
486        let max_id = res.into_iter().max();
487
488        Ok((count, max_id.map(Ulid::from)))
489    }
490}