Skip to main content

rustlavel_queue/
database.rs

1//! The database driver: a `jobs` table, a `failed_jobs` table, and a
2//! reservation that is safe when several workers race for the same row.
3//!
4//! Timestamps are stored as epoch seconds in `bigint` columns rather than as
5//! `timestamptz`. rustlavel-db deliberately leaves a timestamp column as text so
6//! precision survives, and a queue compares times far more often than it shows
7//! them: an integer sorts, compares and indexes without anything having to
8//! agree on a text format.
9
10use crate::job::{BoxFuture, FailedJob, QueuedJob, ReservedJob};
11use crate::queue::{Queue, record_pushed};
12use crate::time::unix_now;
13use rustlavel_core::{Error, Json, Result};
14use rustlavel_db::schema::{Schema, Table};
15use rustlavel_db::{Database, Value, quote_identifier};
16use std::time::Duration;
17
18/// The conventional table names, matching Laravel's.
19pub const JOBS_TABLE: &str = "jobs";
20pub const FAILED_JOBS_TABLE: &str = "failed_jobs";
21
22/// Create the queue's two tables.
23///
24/// Shared by the migration below and by [`DatabaseQueue::migrate`], so a test
25/// that wants its own table names gets exactly the schema production has.
26pub async fn create_tables(schema: &Schema<'_>, jobs: &str, failed: &str) -> Result<()> {
27    schema.create(jobs, define_jobs_table).await?;
28    schema.create(failed, define_failed_jobs_table).await?;
29    Ok(())
30}
31
32/// The `jobs` table. A free function rather than a closure so a test can build
33/// the statements without a database, and see the same schema production gets.
34pub fn define_jobs_table(t: &mut Table) {
35    t.id();
36    t.string("queue");
37    t.string("name");
38    t.json("payload");
39    // Counted up on every reservation, never reset: it is the number that
40    // decides when a job has had enough.
41    t.integer("attempts").default_int(0);
42    t.integer("max_tries").default_int(crate::job::DEFAULT_TRIES as i64);
43    t.integer("retry_after").default_int(60);
44    t.big_integer("available_at");
45    // Null means nobody holds it. Set when a worker reserves it, which is also
46    // what lets a crashed worker's job be reclaimed.
47    t.big_integer("reserved_at").nullable();
48    t.big_integer("created_at");
49    // The exact shape of the reservation query, so finding the next job is an
50    // index scan rather than a table scan taken under a row lock.
51    t.index(&["queue", "reserved_at", "available_at"]);
52}
53
54/// The dead-letter table.
55pub fn define_failed_jobs_table(t: &mut Table) {
56    t.id();
57    t.string("queue");
58    t.string("name");
59    t.json("payload");
60    t.integer("attempts");
61    t.text("error");
62    t.big_integer("failed_at");
63}
64
65rustlavel_db::migration!(
66    CreateQueueTables,
67    "2026_08_29_000001_create_queue_tables",
68    up: |schema| { crate::database::create_tables(schema, JOBS_TABLE, FAILED_JOBS_TABLE).await },
69    down: |schema| {
70        schema.drop(FAILED_JOBS_TABLE).await?;
71        schema.drop(JOBS_TABLE).await
72    },
73);
74
75pub const JOB_PROGRESS_TABLE: &str = "job_progress";
76
77/// Where a job's progress is kept, so a worker in one process and a request
78/// handler in another read the same row.
79pub fn define_job_progress_table(t: &mut Table) {
80    t.id();
81    // The job id, or `chain:<id>` for a pipeline.
82    t.string("key").unique();
83    t.integer("percent");
84    t.string("stage");
85    t.big_integer("updated_at");
86    t.boolean("finished").default_bool(false);
87    t.text("failed").nullable();
88}
89
90// Its own migration, separate from the queue tables: a project that has run
91// `CreateQueueTables` already adds this one and touches nothing else.
92rustlavel_db::migration!(
93    CreateJobProgressTable,
94    "2026_09_17_000001_create_job_progress_table",
95    up: |schema| { schema.create(JOB_PROGRESS_TABLE, crate::database::define_job_progress_table).await },
96    down: |schema| { schema.drop(JOB_PROGRESS_TABLE).await },
97);
98
99/// Progress in a table. The one to use once the worker is its own process.
100#[derive(Clone)]
101pub struct DatabaseProgress {
102    db: Database,
103    table: String,
104}
105
106impl DatabaseProgress {
107    pub fn new(db: Database) -> DatabaseProgress {
108        DatabaseProgress { db, table: JOB_PROGRESS_TABLE.to_string() }
109    }
110
111    pub fn with_table(mut self, table: impl Into<String>) -> DatabaseProgress {
112        self.table = table.into();
113        self
114    }
115}
116
117impl crate::progress::ProgressStore for DatabaseProgress {
118    fn set<'a>(&'a self, key: &'a str, progress: crate::progress::Progress) -> crate::progress::ProgressFuture<'a, ()> {
119        Box::pin(async move {
120            let values: Vec<(&str, Value)> = vec![
121                ("percent", Value::from(progress.percent as i64)),
122                ("stage", Value::from(progress.stage.as_str())),
123                ("updated_at", Value::from(progress.updated_at as i64)),
124                ("finished", Value::from(progress.finished)),
125                ("failed", progress.failed.as_deref().map_or(Value::Null, Value::from)),
126            ];
127
128            // An upsert without a dialect-specific `ON CONFLICT`: update, and
129            // insert if there was nothing to update. Two writers racing on a
130            // brand-new key can both miss the update and one insert then loses
131            // to the unique index — in which case the update is tried again.
132            // Progress is a hint, so the last writer winning is the right
133            // outcome and no write is an error.
134            let updated = self.db.table(&self.table).filter("key", key).update(&self.db, &values).await?;
135            if updated > 0 {
136                return Ok(());
137            }
138            let mut insert = values.clone();
139            insert.push(("key", Value::from(key)));
140            if self.db.table(&self.table).insert_without_id(&self.db, &insert).await.is_err() {
141                self.db.table(&self.table).filter("key", key).update(&self.db, &values).await?;
142            }
143            Ok(())
144        })
145    }
146
147    fn get<'a>(&'a self, key: &'a str) -> crate::progress::ProgressFuture<'a, Option<crate::progress::Progress>> {
148        Box::pin(async move {
149            let row = self.db.table(&self.table).filter("key", key).first(&self.db).await?;
150            Ok(row.map(|row| crate::progress::Progress {
151                percent: row.get::<i64>("percent").unwrap_or(0).clamp(0, 100) as u8,
152                stage: row.get::<String>("stage").unwrap_or_default(),
153                updated_at: row.get::<i64>("updated_at").unwrap_or(0).max(0) as u64,
154                finished: row.get::<bool>("finished").unwrap_or(false),
155                failed: row.get::<String>("failed").ok().filter(|f| !f.is_empty()),
156            }))
157        })
158    }
159
160    fn purge<'a>(&'a self, before: u64) -> crate::progress::ProgressFuture<'a, usize> {
161        Box::pin(async move {
162            let removed = self
163                .db
164                .table(&self.table)
165                .filter_op("updated_at", "<", before as i64)
166                .delete(&self.db)
167                .await?;
168            Ok(removed as usize)
169        })
170    }
171}
172
173/// A queue backed by two database tables.
174///
175/// Survives a restart, and is shared by every process pointed at the same
176/// database — which is the whole reason to prefer it over the memory driver.
177#[derive(Clone)]
178pub struct DatabaseQueue {
179    db: Database,
180    jobs: String,
181    failed: String,
182    /// Both names pre-quoted, so no statement builds an identifier at run time.
183    jobs_sql: String,
184    failed_sql: String,
185}
186
187impl DatabaseQueue {
188    /// Use the conventional `jobs` and `failed_jobs` tables.
189    pub fn new(db: Database) -> Self {
190        DatabaseQueue::with_tables(db, JOBS_TABLE, FAILED_JOBS_TABLE)
191            .expect("the built-in table names are valid identifiers")
192    }
193
194    /// Use table names of your own. Validated here, once, rather than on every
195    /// statement.
196    pub fn with_tables(db: Database, jobs: &str, failed: &str) -> Result<Self> {
197        Ok(DatabaseQueue {
198            jobs_sql: quote_identifier(jobs)?,
199            failed_sql: quote_identifier(failed)?,
200            jobs: jobs.to_string(),
201            failed: failed.to_string(),
202            db,
203        })
204    }
205
206    pub fn database(&self) -> &Database {
207        &self.db
208    }
209
210    /// Create the tables directly, for an application that would rather not
211    /// register [`CreateQueueTables`] in its migration registry.
212    pub async fn migrate(&self) -> Result<()> {
213        let schema = Schema::new(&self.db);
214        create_tables(&schema, &self.jobs, &self.failed).await?;
215        // The progress table too. `create_if_missing`, not `has_table` then
216        // `create`: this runs on every boot, and two workers booting at once
217        // would both see no table and one would fail — which is exactly what
218        // happened to the test suite the first time this ran.
219        schema.create_if_missing(JOB_PROGRESS_TABLE, define_job_progress_table).await
220    }
221
222    /// Drop the tables. Intended for tests and `queue:fresh`.
223    pub async fn drop_tables(&self) -> Result<()> {
224        let schema = Schema::new(&self.db);
225        schema.drop(&self.failed).await?;
226        schema.drop(&self.jobs).await
227    }
228
229    /// Take the next job, holding it against every other worker.
230    ///
231    /// `select ... for update skip locked` is the primitive this needs, and
232    /// nothing weaker will do:
233    ///
234    /// * A plain `select ... limit 1` followed by an `update` is a read-then-
235    ///   write race. Two workers read the same row, both update it, and the job
236    ///   runs twice — the one failure mode a queue may not have.
237    /// * `for update` alone fixes correctness by making the second worker block
238    ///   on the row lock, but it also serialises the pool: with ten workers, nine
239    ///   are queued behind the same row instead of taking the nine rows after it.
240    /// * `for update skip locked` locks the row this transaction picked and
241    ///   makes every other transaction *step over* it rather than wait. Ten
242    ///   workers take ten different rows in parallel, and no row is ever handed
243    ///   out twice, because the lock is held until this transaction commits the
244    ///   `reserved_at` that closes the row to everyone else.
245    ///
246    /// The select and the update are in one transaction for exactly that
247    /// reason: the lock only lasts as long as the transaction does, so the row
248    /// must be marked reserved before it is released.
249    async fn reserve(&self, queue: &str) -> Result<Option<ReservedJob>> {
250        let now = unix_now();
251        let mut tx = self.db.begin().await?;
252
253        let row = tx
254            .select_one(
255                &format!(
256                    "select id, name, payload, attempts, max_tries, retry_after \
257                     from {} \
258                     where queue = $1 and reserved_at is null and available_at <= $2 \
259                     order by id \
260                     for update skip locked \
261                     limit 1",
262                    self.jobs_sql
263                ),
264                &[Value::from(queue), Value::from(now)],
265            )
266            .await?;
267
268        let Some(row) = row else {
269            // Nothing to do, but the transaction still has to end — an open one
270            // would be discarded by the pool rather than reused.
271            tx.rollback().await?;
272            return Ok(None);
273        };
274
275        let id = row.get::<i64>("id")?;
276        let attempts = row.get::<i64>("attempts")? as u32 + 1;
277        let retry_after = Duration::from_secs(row.get::<i64>("retry_after")?.max(0) as u64);
278
279        tx.execute(
280            &format!(
281                "update {} set attempts = $1, reserved_at = $2 where id = $3",
282                self.jobs_sql
283            ),
284            &[Value::from(attempts), Value::from(now), Value::from(id)],
285        )
286        .await?;
287
288        tx.commit().await?;
289
290        // The payload column may hold a chain envelope; `with_stored_payload`
291        // unfolds it and leaves an ordinary payload alone.
292        let stored: Json = row.get::<Json>("payload")?;
293        let job = QueuedJob {
294            name: row.get::<String>("name")?,
295            payload: Json::Null,
296            queue: queue.to_string(),
297            max_tries: row.get::<i64>("max_tries")?.max(1) as u32,
298            retry_after,
299            delay: Duration::ZERO,
300            chain: None,
301            then: Vec::new(),
302        }
303        .with_stored_payload(stored);
304
305        Ok(Some(ReservedJob {
306            id: id.to_string(),
307            job,
308            attempts,
309        }))
310    }
311
312    /// Release jobs whose worker never came back.
313    ///
314    /// A worker that is killed between reserving and finishing leaves
315    /// `reserved_at` set forever. Once the job's own `retry_after` has passed
316    /// with no result, the job is assumed orphaned and goes back on the queue.
317    ///
318    /// Called only when the queue looks empty, so the common path — a worker
319    /// picking up one of many waiting jobs — pays nothing for it.
320    pub async fn reclaim_expired(&self, queue: &str) -> Result<u64> {
321        let now = unix_now();
322
323        self.db
324            .execute(
325                &format!(
326                    "update {} set reserved_at = null, available_at = $1 \
327                     where queue = $2 and reserved_at is not null and reserved_at + retry_after < $3",
328                    self.jobs_sql
329                ),
330                &[Value::from(now), Value::from(queue), Value::from(now)],
331            )
332            .await
333    }
334
335    fn row_to_failed(row: &rustlavel_db::Row) -> Result<FailedJob> {
336        Ok(FailedJob {
337            id: row.get::<i64>("id")?.to_string(),
338            name: row.get::<String>("name")?,
339            queue: row.get::<String>("queue")?,
340            payload: row.get::<Json>("payload")?,
341            attempts: row.get::<i64>("attempts")?.max(0) as u32,
342            error: row.get::<String>("error")?,
343            failed_at: row.get::<i64>("failed_at")?,
344        })
345    }
346
347    fn job_id(job: &ReservedJob) -> Result<i64> {
348        job.id.parse::<i64>().map_err(|_| {
349            Error::msg(format!(
350                "`{}` is not an id from the database queue. Was this job reserved by a \
351                 different driver?",
352                job.id
353            ))
354        })
355    }
356}
357
358impl Queue for DatabaseQueue {
359    fn driver(&self) -> &'static str {
360        "database"
361    }
362
363    fn push(&self, job: QueuedJob) -> BoxFuture<'_, Result<String>> {
364        Box::pin(async move {
365            let now = unix_now();
366            let id = self
367                .db
368                .table(&self.jobs)
369                .insert(
370                    &self.db,
371                    &[
372                        ("queue", Value::from(job.queue.as_str())),
373                        ("name", Value::from(job.name.as_str())),
374                        ("payload", Value::from(job.stored_payload())),
375                        ("attempts", Value::from(0)),
376                        ("max_tries", Value::from(job.max_tries)),
377                        ("retry_after", Value::from(job.retry_after.as_secs() as i64)),
378                        ("available_at", Value::from(now + job.delay.as_secs() as i64)),
379                        ("created_at", Value::from(now)),
380                    ],
381                )
382                .await?
383                .to_string();
384
385            record_pushed(self.driver(), &job, &id);
386            Ok(id)
387        })
388    }
389
390    fn pop<'a>(&'a self, queue: &'a str) -> BoxFuture<'a, Result<Option<ReservedJob>>> {
391        Box::pin(async move {
392            if let Some(job) = self.reserve(queue).await? {
393                return Ok(Some(job));
394            }
395
396            // Nothing was ready. Before reporting an empty queue, check whether
397            // a dead worker is sitting on something, and only re-select if it
398            // actually released anything.
399            if self.reclaim_expired(queue).await? > 0 {
400                return self.reserve(queue).await;
401            }
402
403            Ok(None)
404        })
405    }
406
407    fn size<'a>(&'a self, queue: &'a str) -> BoxFuture<'a, Result<u64>> {
408        Box::pin(async move {
409            let count = self.db.table(&self.jobs).filter("queue", queue).count(&self.db).await?;
410            Ok(count.max(0) as u64)
411        })
412    }
413
414    fn delete<'a>(&'a self, job: &'a ReservedJob) -> BoxFuture<'a, Result<()>> {
415        Box::pin(async move {
416            self.db
417                .table(&self.jobs)
418                .filter("id", DatabaseQueue::job_id(job)?)
419                .delete(&self.db)
420                .await?;
421            Ok(())
422        })
423    }
424
425    fn release<'a>(&'a self, job: &'a ReservedJob, delay: Duration) -> BoxFuture<'a, Result<()>> {
426        Box::pin(async move {
427            self.db
428                .table(&self.jobs)
429                .filter("id", DatabaseQueue::job_id(job)?)
430                .update(
431                    &self.db,
432                    &[
433                        ("reserved_at", Value::Null),
434                        ("available_at", Value::from(unix_now() + delay.as_secs() as i64)),
435                    ],
436                )
437                .await?;
438            Ok(())
439        })
440    }
441
442    fn fail<'a>(&'a self, job: &'a ReservedJob, error: &'a str) -> BoxFuture<'a, Result<()>> {
443        Box::pin(async move {
444            let id = DatabaseQueue::job_id(job)?;
445
446            // One transaction, so a job can never be in both tables or neither.
447            let mut tx = self.db.begin().await?;
448
449            tx.execute(
450                &format!(
451                    "insert into {} (queue, name, payload, attempts, error, failed_at) \
452                     values ($1, $2, $3, $4, $5, $6)",
453                    self.failed_sql
454                ),
455                &[
456                    Value::from(job.job.queue.as_str()),
457                    Value::from(job.job.name.as_str()),
458                    // The envelope, chain included, so a failed step's
459                    // remaining steps are visible in the dead-letter table
460                    // rather than silently gone.
461                    Value::from(job.job.stored_payload()),
462                    Value::from(job.attempts),
463                    Value::from(error),
464                    Value::from(unix_now()),
465                ],
466            )
467            .await?;
468
469            tx.execute(&format!("delete from {} where id = $1", self.jobs_sql), &[Value::from(id)])
470                .await?;
471
472            tx.commit().await
473        })
474    }
475
476    fn failed_jobs(&self) -> BoxFuture<'_, Result<Vec<FailedJob>>> {
477        Box::pin(async move {
478            let rows = self
479                .db
480                .select(&format!("select * from {} order by id", self.failed_sql), &[])
481                .await?;
482            rows.iter().map(DatabaseQueue::row_to_failed).collect()
483        })
484    }
485
486    fn clear<'a>(&'a self, queue: &'a str) -> BoxFuture<'a, Result<u64>> {
487        Box::pin(async move {
488            self.db.table(&self.jobs).filter("queue", queue).delete(&self.db).await
489        })
490    }
491}
492
493#[cfg(test)]
494mod tests {
495    use super::*;
496    use rustlavel_db::Migration;
497
498    #[test]
499    fn table_names_are_validated_once_at_construction() {
500        let db = Database::lazy(rustlavel_db::DatabaseConfig::default())
501            .expect("the default configuration names a driver that exists");
502
503        assert!(DatabaseQueue::with_tables(db.clone(), "app_jobs", "app_failed").is_ok());
504
505        let error = match DatabaseQueue::with_tables(db, "jobs; drop table users", "failed") {
506            Err(error) => error.to_string(),
507            Ok(_) => panic!("an injected table name should have been rejected"),
508        };
509        assert!(error.contains("not a valid SQL identifier"), "{error}");
510    }
511
512    #[test]
513    fn the_jobs_table_has_the_columns_the_driver_reads() {
514        // The tables assert PostgreSQL's spelling; the dialect layer covers the
515        // other databases.
516        let jobs = rustlavel_db::schema::create_statements(
517            &rustlavel_db::dialect::Postgres,
518            JOBS_TABLE,
519            define_jobs_table,
520        )
521        .unwrap();
522
523        for column in [
524            r#""id" bigserial primary key"#,
525            r#""queue" varchar(255) not null"#,
526            r#""name" varchar(255) not null"#,
527            r#""payload" jsonb not null"#,
528            r#""attempts" integer not null default 0"#,
529            r#""max_tries" integer not null default 3"#,
530            r#""retry_after" integer not null default 60"#,
531            r#""available_at" bigint not null"#,
532            r#""created_at" bigint not null"#,
533        ] {
534            assert!(jobs[0].contains(column), "expected {column} in:\n{}", jobs[0]);
535        }
536
537        // Nullable on purpose: null is what "nobody holds this job" means, and
538        // it is what the reservation query filters on.
539        assert!(jobs[0].contains(r#""reserved_at" bigint,"#), "{}", jobs[0]);
540        assert!(!jobs[0].contains(r#""reserved_at" bigint not null"#), "{}", jobs[0]);
541
542        assert!(
543            jobs[1].contains(r#"on "jobs" ("queue", "reserved_at", "available_at")"#),
544            "the reservation query needs a covering index:\n{}",
545            jobs[1]
546        );
547    }
548
549    #[test]
550    fn the_failed_jobs_table_keeps_everything_needed_to_diagnose_a_job() {
551        let failed =
552            rustlavel_db::schema::create_statements(
553                &rustlavel_db::dialect::Postgres,
554                FAILED_JOBS_TABLE,
555                define_failed_jobs_table,
556            )
557                .unwrap();
558
559        for column in [
560            r#""queue" varchar(255) not null"#,
561            r#""name" varchar(255) not null"#,
562            r#""payload" jsonb not null"#,
563            r#""attempts" integer not null"#,
564            r#""error" text not null"#,
565            r#""failed_at" bigint not null"#,
566        ] {
567            assert!(failed[0].contains(column), "expected {column} in:\n{}", failed[0]);
568        }
569    }
570
571    #[test]
572    fn the_migration_is_registered_under_a_sortable_name() {
573        assert_eq!(CreateQueueTables.name(), "2026_08_29_000001_create_queue_tables");
574    }
575
576    #[test]
577    fn an_id_from_another_driver_is_rejected_rather_than_silently_ignored() {
578        let job = ReservedJob {
579            id: "memory-7".to_string(),
580            job: QueuedJob::new("x", Json::Null),
581            attempts: 1,
582        };
583
584        let error = DatabaseQueue::job_id(&job).unwrap_err().to_string();
585        assert!(error.contains("not an id from the database queue"), "{error}");
586    }
587}