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
75/// A queue backed by two database tables.
76///
77/// Survives a restart, and is shared by every process pointed at the same
78/// database — which is the whole reason to prefer it over the memory driver.
79#[derive(Clone)]
80pub struct DatabaseQueue {
81    db: Database,
82    jobs: String,
83    failed: String,
84    /// Both names pre-quoted, so no statement builds an identifier at run time.
85    jobs_sql: String,
86    failed_sql: String,
87}
88
89impl DatabaseQueue {
90    /// Use the conventional `jobs` and `failed_jobs` tables.
91    pub fn new(db: Database) -> Self {
92        DatabaseQueue::with_tables(db, JOBS_TABLE, FAILED_JOBS_TABLE)
93            .expect("the built-in table names are valid identifiers")
94    }
95
96    /// Use table names of your own. Validated here, once, rather than on every
97    /// statement.
98    pub fn with_tables(db: Database, jobs: &str, failed: &str) -> Result<Self> {
99        Ok(DatabaseQueue {
100            jobs_sql: quote_identifier(jobs)?,
101            failed_sql: quote_identifier(failed)?,
102            jobs: jobs.to_string(),
103            failed: failed.to_string(),
104            db,
105        })
106    }
107
108    pub fn database(&self) -> &Database {
109        &self.db
110    }
111
112    /// Create the tables directly, for an application that would rather not
113    /// register [`CreateQueueTables`] in its migration registry.
114    pub async fn migrate(&self) -> Result<()> {
115        let schema = Schema::new(&self.db);
116        create_tables(&schema, &self.jobs, &self.failed).await
117    }
118
119    /// Drop the tables. Intended for tests and `queue:fresh`.
120    pub async fn drop_tables(&self) -> Result<()> {
121        let schema = Schema::new(&self.db);
122        schema.drop(&self.failed).await?;
123        schema.drop(&self.jobs).await
124    }
125
126    /// Take the next job, holding it against every other worker.
127    ///
128    /// `select ... for update skip locked` is the primitive this needs, and
129    /// nothing weaker will do:
130    ///
131    /// * A plain `select ... limit 1` followed by an `update` is a read-then-
132    ///   write race. Two workers read the same row, both update it, and the job
133    ///   runs twice — the one failure mode a queue may not have.
134    /// * `for update` alone fixes correctness by making the second worker block
135    ///   on the row lock, but it also serialises the pool: with ten workers, nine
136    ///   are queued behind the same row instead of taking the nine rows after it.
137    /// * `for update skip locked` locks the row this transaction picked and
138    ///   makes every other transaction *step over* it rather than wait. Ten
139    ///   workers take ten different rows in parallel, and no row is ever handed
140    ///   out twice, because the lock is held until this transaction commits the
141    ///   `reserved_at` that closes the row to everyone else.
142    ///
143    /// The select and the update are in one transaction for exactly that
144    /// reason: the lock only lasts as long as the transaction does, so the row
145    /// must be marked reserved before it is released.
146    async fn reserve(&self, queue: &str) -> Result<Option<ReservedJob>> {
147        let now = unix_now();
148        let mut tx = self.db.begin().await?;
149
150        let row = tx
151            .select_one(
152                &format!(
153                    "select id, name, payload, attempts, max_tries, retry_after \
154                     from {} \
155                     where queue = $1 and reserved_at is null and available_at <= $2 \
156                     order by id \
157                     for update skip locked \
158                     limit 1",
159                    self.jobs_sql
160                ),
161                &[Value::from(queue), Value::from(now)],
162            )
163            .await?;
164
165        let Some(row) = row else {
166            // Nothing to do, but the transaction still has to end — an open one
167            // would be discarded by the pool rather than reused.
168            tx.rollback().await?;
169            return Ok(None);
170        };
171
172        let id = row.get::<i64>("id")?;
173        let attempts = row.get::<i64>("attempts")? as u32 + 1;
174        let retry_after = Duration::from_secs(row.get::<i64>("retry_after")?.max(0) as u64);
175
176        tx.execute(
177            &format!(
178                "update {} set attempts = $1, reserved_at = $2 where id = $3",
179                self.jobs_sql
180            ),
181            &[Value::from(attempts), Value::from(now), Value::from(id)],
182        )
183        .await?;
184
185        tx.commit().await?;
186
187        Ok(Some(ReservedJob {
188            id: id.to_string(),
189            job: QueuedJob {
190                name: row.get::<String>("name")?,
191                payload: row.get::<Json>("payload")?,
192                queue: queue.to_string(),
193                max_tries: row.get::<i64>("max_tries")?.max(1) as u32,
194                retry_after,
195                delay: Duration::ZERO,
196            },
197            attempts,
198        }))
199    }
200
201    /// Release jobs whose worker never came back.
202    ///
203    /// A worker that is killed between reserving and finishing leaves
204    /// `reserved_at` set forever. Once the job's own `retry_after` has passed
205    /// with no result, the job is assumed orphaned and goes back on the queue.
206    ///
207    /// Called only when the queue looks empty, so the common path — a worker
208    /// picking up one of many waiting jobs — pays nothing for it.
209    pub async fn reclaim_expired(&self, queue: &str) -> Result<u64> {
210        let now = unix_now();
211
212        self.db
213            .execute(
214                &format!(
215                    "update {} set reserved_at = null, available_at = $1 \
216                     where queue = $2 and reserved_at is not null and reserved_at + retry_after < $3",
217                    self.jobs_sql
218                ),
219                &[Value::from(now), Value::from(queue), Value::from(now)],
220            )
221            .await
222    }
223
224    fn row_to_failed(row: &rustlavel_db::Row) -> Result<FailedJob> {
225        Ok(FailedJob {
226            id: row.get::<i64>("id")?.to_string(),
227            name: row.get::<String>("name")?,
228            queue: row.get::<String>("queue")?,
229            payload: row.get::<Json>("payload")?,
230            attempts: row.get::<i64>("attempts")?.max(0) as u32,
231            error: row.get::<String>("error")?,
232            failed_at: row.get::<i64>("failed_at")?,
233        })
234    }
235
236    fn job_id(job: &ReservedJob) -> Result<i64> {
237        job.id.parse::<i64>().map_err(|_| {
238            Error::msg(format!(
239                "`{}` is not an id from the database queue. Was this job reserved by a \
240                 different driver?",
241                job.id
242            ))
243        })
244    }
245}
246
247impl Queue for DatabaseQueue {
248    fn driver(&self) -> &'static str {
249        "database"
250    }
251
252    fn push(&self, job: QueuedJob) -> BoxFuture<'_, Result<String>> {
253        Box::pin(async move {
254            let now = unix_now();
255            let id = self
256                .db
257                .table(&self.jobs)
258                .insert(
259                    &self.db,
260                    &[
261                        ("queue", Value::from(job.queue.as_str())),
262                        ("name", Value::from(job.name.as_str())),
263                        ("payload", Value::from(job.payload.clone())),
264                        ("attempts", Value::from(0)),
265                        ("max_tries", Value::from(job.max_tries)),
266                        ("retry_after", Value::from(job.retry_after.as_secs() as i64)),
267                        ("available_at", Value::from(now + job.delay.as_secs() as i64)),
268                        ("created_at", Value::from(now)),
269                    ],
270                )
271                .await?
272                .to_string();
273
274            record_pushed(self.driver(), &job, &id);
275            Ok(id)
276        })
277    }
278
279    fn pop<'a>(&'a self, queue: &'a str) -> BoxFuture<'a, Result<Option<ReservedJob>>> {
280        Box::pin(async move {
281            if let Some(job) = self.reserve(queue).await? {
282                return Ok(Some(job));
283            }
284
285            // Nothing was ready. Before reporting an empty queue, check whether
286            // a dead worker is sitting on something, and only re-select if it
287            // actually released anything.
288            if self.reclaim_expired(queue).await? > 0 {
289                return self.reserve(queue).await;
290            }
291
292            Ok(None)
293        })
294    }
295
296    fn size<'a>(&'a self, queue: &'a str) -> BoxFuture<'a, Result<u64>> {
297        Box::pin(async move {
298            let count = self.db.table(&self.jobs).filter("queue", queue).count(&self.db).await?;
299            Ok(count.max(0) as u64)
300        })
301    }
302
303    fn delete<'a>(&'a self, job: &'a ReservedJob) -> BoxFuture<'a, Result<()>> {
304        Box::pin(async move {
305            self.db
306                .table(&self.jobs)
307                .filter("id", DatabaseQueue::job_id(job)?)
308                .delete(&self.db)
309                .await?;
310            Ok(())
311        })
312    }
313
314    fn release<'a>(&'a self, job: &'a ReservedJob, delay: Duration) -> BoxFuture<'a, Result<()>> {
315        Box::pin(async move {
316            self.db
317                .table(&self.jobs)
318                .filter("id", DatabaseQueue::job_id(job)?)
319                .update(
320                    &self.db,
321                    &[
322                        ("reserved_at", Value::Null),
323                        ("available_at", Value::from(unix_now() + delay.as_secs() as i64)),
324                    ],
325                )
326                .await?;
327            Ok(())
328        })
329    }
330
331    fn fail<'a>(&'a self, job: &'a ReservedJob, error: &'a str) -> BoxFuture<'a, Result<()>> {
332        Box::pin(async move {
333            let id = DatabaseQueue::job_id(job)?;
334
335            // One transaction, so a job can never be in both tables or neither.
336            let mut tx = self.db.begin().await?;
337
338            tx.execute(
339                &format!(
340                    "insert into {} (queue, name, payload, attempts, error, failed_at) \
341                     values ($1, $2, $3, $4, $5, $6)",
342                    self.failed_sql
343                ),
344                &[
345                    Value::from(job.job.queue.as_str()),
346                    Value::from(job.job.name.as_str()),
347                    Value::from(job.job.payload.clone()),
348                    Value::from(job.attempts),
349                    Value::from(error),
350                    Value::from(unix_now()),
351                ],
352            )
353            .await?;
354
355            tx.execute(&format!("delete from {} where id = $1", self.jobs_sql), &[Value::from(id)])
356                .await?;
357
358            tx.commit().await
359        })
360    }
361
362    fn failed_jobs(&self) -> BoxFuture<'_, Result<Vec<FailedJob>>> {
363        Box::pin(async move {
364            let rows = self
365                .db
366                .select(&format!("select * from {} order by id", self.failed_sql), &[])
367                .await?;
368            rows.iter().map(DatabaseQueue::row_to_failed).collect()
369        })
370    }
371
372    fn clear<'a>(&'a self, queue: &'a str) -> BoxFuture<'a, Result<u64>> {
373        Box::pin(async move {
374            self.db.table(&self.jobs).filter("queue", queue).delete(&self.db).await
375        })
376    }
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382    use rustlavel_db::Migration;
383
384    #[test]
385    fn table_names_are_validated_once_at_construction() {
386        let db = Database::lazy(rustlavel_db::DatabaseConfig::default())
387            .expect("the default configuration names a driver that exists");
388
389        assert!(DatabaseQueue::with_tables(db.clone(), "app_jobs", "app_failed").is_ok());
390
391        let error = match DatabaseQueue::with_tables(db, "jobs; drop table users", "failed") {
392            Err(error) => error.to_string(),
393            Ok(_) => panic!("an injected table name should have been rejected"),
394        };
395        assert!(error.contains("not a valid SQL identifier"), "{error}");
396    }
397
398    #[test]
399    fn the_jobs_table_has_the_columns_the_driver_reads() {
400        // The tables assert PostgreSQL's spelling; the dialect layer covers the
401        // other databases.
402        let jobs = rustlavel_db::schema::create_statements(
403            &rustlavel_db::dialect::Postgres,
404            JOBS_TABLE,
405            define_jobs_table,
406        )
407        .unwrap();
408
409        for column in [
410            r#""id" bigserial primary key"#,
411            r#""queue" varchar(255) not null"#,
412            r#""name" varchar(255) not null"#,
413            r#""payload" jsonb not null"#,
414            r#""attempts" integer not null default 0"#,
415            r#""max_tries" integer not null default 3"#,
416            r#""retry_after" integer not null default 60"#,
417            r#""available_at" bigint not null"#,
418            r#""created_at" bigint not null"#,
419        ] {
420            assert!(jobs[0].contains(column), "expected {column} in:\n{}", jobs[0]);
421        }
422
423        // Nullable on purpose: null is what "nobody holds this job" means, and
424        // it is what the reservation query filters on.
425        assert!(jobs[0].contains(r#""reserved_at" bigint,"#), "{}", jobs[0]);
426        assert!(!jobs[0].contains(r#""reserved_at" bigint not null"#), "{}", jobs[0]);
427
428        assert!(
429            jobs[1].contains(r#"on "jobs" ("queue", "reserved_at", "available_at")"#),
430            "the reservation query needs a covering index:\n{}",
431            jobs[1]
432        );
433    }
434
435    #[test]
436    fn the_failed_jobs_table_keeps_everything_needed_to_diagnose_a_job() {
437        let failed =
438            rustlavel_db::schema::create_statements(
439                &rustlavel_db::dialect::Postgres,
440                FAILED_JOBS_TABLE,
441                define_failed_jobs_table,
442            )
443                .unwrap();
444
445        for column in [
446            r#""queue" varchar(255) not null"#,
447            r#""name" varchar(255) not null"#,
448            r#""payload" jsonb not null"#,
449            r#""attempts" integer not null"#,
450            r#""error" text not null"#,
451            r#""failed_at" bigint not null"#,
452        ] {
453            assert!(failed[0].contains(column), "expected {column} in:\n{}", failed[0]);
454        }
455    }
456
457    #[test]
458    fn the_migration_is_registered_under_a_sortable_name() {
459        assert_eq!(CreateQueueTables.name(), "2026_08_29_000001_create_queue_tables");
460    }
461
462    #[test]
463    fn an_id_from_another_driver_is_rejected_rather_than_silently_ignored() {
464        let job = ReservedJob {
465            id: "memory-7".to_string(),
466            job: QueuedJob::new("x", Json::Null),
467            attempts: 1,
468        };
469
470        let error = DatabaseQueue::job_id(&job).unwrap_err().to_string();
471        assert!(error.contains("not an id from the database queue"), "{error}");
472    }
473}