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::dialect::quote_qualified;
16use rustlavel_db::{Database, Value};
17use std::time::Duration;
18
19/// The conventional table names, matching Laravel's.
20pub const JOBS_TABLE: &str = "jobs";
21pub const FAILED_JOBS_TABLE: &str = "failed_jobs";
22
23/// Create the queue's two tables.
24///
25/// Shared by the migration below and by [`DatabaseQueue::migrate`], so a test
26/// that wants its own table names gets exactly the schema production has.
27pub async fn create_tables(schema: &Schema<'_>, jobs: &str, failed: &str) -> Result<()> {
28    schema.create(jobs, define_jobs_table).await?;
29    schema.create(failed, define_failed_jobs_table).await?;
30    Ok(())
31}
32
33/// The `jobs` table. A free function rather than a closure so a test can build
34/// the statements without a database, and see the same schema production gets.
35pub fn define_jobs_table(t: &mut Table) {
36    t.id();
37    t.string("queue");
38    t.string("name");
39    t.json("payload");
40    // Counted up on every reservation, never reset: it is the number that
41    // decides when a job has had enough.
42    t.integer("attempts").default_int(0);
43    t.integer("max_tries").default_int(crate::job::DEFAULT_TRIES as i64);
44    t.integer("retry_after").default_int(60);
45    t.big_integer("available_at");
46    // Null means nobody holds it. Set when a worker reserves it, which is also
47    // what lets a crashed worker's job be reclaimed.
48    t.big_integer("reserved_at").nullable();
49    t.big_integer("created_at");
50    // The exact shape of the reservation query, so finding the next job is an
51    // index scan rather than a table scan taken under a row lock.
52    t.index(&["queue", "reserved_at", "available_at"]);
53}
54
55/// The dead-letter table.
56pub fn define_failed_jobs_table(t: &mut Table) {
57    t.id();
58    t.string("queue");
59    t.string("name");
60    t.json("payload");
61    t.integer("attempts");
62    t.text("error");
63    t.big_integer("failed_at");
64}
65
66rustlavel_db::migration!(
67    CreateQueueTables,
68    "2026_08_29_000001_create_queue_tables",
69    up: |schema| { crate::database::create_tables(schema, JOBS_TABLE, FAILED_JOBS_TABLE).await },
70    down: |schema| {
71        schema.drop(FAILED_JOBS_TABLE).await?;
72        schema.drop(JOBS_TABLE).await
73    },
74);
75
76pub const JOB_PROGRESS_TABLE: &str = "job_progress";
77
78/// Where a job's progress is kept, so a worker in one process and a request
79/// handler in another read the same row.
80pub fn define_job_progress_table(t: &mut Table) {
81    t.id();
82    // The job id, or `chain:<id>` for a pipeline.
83    t.string("key").unique();
84    t.integer("percent");
85    t.string("stage");
86    t.big_integer("updated_at");
87    t.boolean("finished").default_bool(false);
88    t.text("failed").nullable();
89}
90
91// Its own migration, separate from the queue tables: a project that has run
92// `CreateQueueTables` already adds this one and touches nothing else.
93rustlavel_db::migration!(
94    CreateJobProgressTable,
95    "2026_09_17_000001_create_job_progress_table",
96    up: |schema| { schema.create(JOB_PROGRESS_TABLE, crate::database::define_job_progress_table).await },
97    down: |schema| { schema.drop(JOB_PROGRESS_TABLE).await },
98);
99
100/// Progress in a table. The one to use once the worker is its own process.
101#[derive(Clone)]
102pub struct DatabaseProgress {
103    db: Database,
104    table: String,
105}
106
107impl DatabaseProgress {
108    pub fn new(db: Database) -> DatabaseProgress {
109        DatabaseProgress { db, table: JOB_PROGRESS_TABLE.to_string() }
110    }
111
112    pub fn with_table(mut self, table: impl Into<String>) -> DatabaseProgress {
113        self.table = table.into();
114        self
115    }
116}
117
118impl crate::progress::ProgressStore for DatabaseProgress {
119    fn set<'a>(&'a self, key: &'a str, progress: crate::progress::Progress) -> crate::progress::ProgressFuture<'a, ()> {
120        Box::pin(async move {
121            let values: Vec<(&str, Value)> = vec![
122                ("percent", Value::from(progress.percent as i64)),
123                ("stage", Value::from(progress.stage.as_str())),
124                ("updated_at", Value::from(progress.updated_at as i64)),
125                ("finished", Value::from(progress.finished)),
126                ("failed", progress.failed.as_deref().map_or(Value::Null, Value::from)),
127            ];
128
129            // An upsert without a dialect-specific `ON CONFLICT`: update, and
130            // insert if there was nothing to update. Two writers racing on a
131            // brand-new key can both miss the update and one insert then loses
132            // to the unique index — in which case the update is tried again.
133            // Progress is a hint, so the last writer winning is the right
134            // outcome and no write is an error.
135            let updated = self.db.table(&self.table).filter("key", key).update(&self.db, &values).await?;
136            if updated > 0 {
137                return Ok(());
138            }
139            let mut insert = values.clone();
140            insert.push(("key", Value::from(key)));
141            if self.db.table(&self.table).insert_without_id(&self.db, &insert).await.is_err() {
142                self.db.table(&self.table).filter("key", key).update(&self.db, &values).await?;
143            }
144            Ok(())
145        })
146    }
147
148    fn get<'a>(&'a self, key: &'a str) -> crate::progress::ProgressFuture<'a, Option<crate::progress::Progress>> {
149        Box::pin(async move {
150            let row = self.db.table(&self.table).filter("key", key).first(&self.db).await?;
151            Ok(row.map(|row| crate::progress::Progress {
152                percent: row.get::<i64>("percent").unwrap_or(0).clamp(0, 100) as u8,
153                stage: row.get::<String>("stage").unwrap_or_default(),
154                updated_at: row.get::<i64>("updated_at").unwrap_or(0).max(0) as u64,
155                finished: row.get::<bool>("finished").unwrap_or(false),
156                failed: row.get::<String>("failed").ok().filter(|f| !f.is_empty()),
157            }))
158        })
159    }
160
161    fn purge<'a>(&'a self, before: u64) -> crate::progress::ProgressFuture<'a, usize> {
162        Box::pin(async move {
163            let removed = self
164                .db
165                .table(&self.table)
166                .filter_op("updated_at", "<", before as i64)
167                .delete(&self.db)
168                .await?;
169            Ok(removed as usize)
170        })
171    }
172}
173
174/// A queue backed by two database tables.
175///
176/// Survives a restart, and is shared by every process pointed at the same
177/// database — which is the whole reason to prefer it over the memory driver.
178#[derive(Clone)]
179pub struct DatabaseQueue {
180    db: Database,
181    jobs: String,
182    failed: String,
183    /// Both names pre-quoted, so no statement builds an identifier at run time.
184    jobs_sql: String,
185    failed_sql: String,
186}
187
188/// The statement that claims one job, built for whichever database this is
189/// talking to.
190///
191/// A free function so its shape can be asserted without opening a database.
192/// Four things here are database-specific and none may be written literally:
193/// the **table name's quoting**, the **placeholders** (`$1` on PostgreSQL,
194/// `?` elsewhere), the **row-claiming clause** (a suffix on PostgreSQL and
195/// MySQL, a table hint on SQL Server, nothing at all on SQLite), and
196/// **`limit`**, which SQL Server spells `offset … fetch next … rows only`.
197///
198/// Every one of those was once written the PostgreSQL way, which made this
199/// the only package in the workspace that could not run on the other
200/// databases the framework claims to support.
201fn reserve_sql(jobs_sql: &str, dialect: &dyn rustlavel_db::Dialect) -> String {
202    let (hint, lock) = dialect.skip_locked();
203    format!(
204        "select id, name, payload, attempts, max_tries, retry_after \
205         from {jobs_sql}{hint} \
206         where queue = {} and reserved_at is null and available_at <= {} \
207         order by id{lock}{}",
208        dialect.placeholder(1),
209        dialect.placeholder(2),
210        dialect.limit_offset(Some(1), None, true)
211    )
212}
213
214impl DatabaseQueue {
215    /// Use the conventional `jobs` and `failed_jobs` tables.
216    pub fn new(db: Database) -> Self {
217        DatabaseQueue::with_tables(db, JOBS_TABLE, FAILED_JOBS_TABLE)
218            .expect("the built-in table names are valid identifiers")
219    }
220
221    /// Use table names of your own. Validated and quoted here, once, for the
222    /// database this queue is talking to — `"jobs"` on PostgreSQL and SQLite,
223    /// `` `jobs` `` on MySQL, `[jobs]` on SQL Server. The shared
224    /// `quote_identifier` helper always writes double quotes, which MySQL
225    /// reads as a string literal rather than a table.
226    pub fn with_tables(db: Database, jobs: &str, failed: &str) -> Result<Self> {
227        Ok(DatabaseQueue {
228            jobs_sql: quote_qualified(db.dialect(), jobs)?,
229            failed_sql: quote_qualified(db.dialect(), failed)?,
230            jobs: jobs.to_string(),
231            failed: failed.to_string(),
232            db,
233        })
234    }
235
236    pub fn database(&self) -> &Database {
237        &self.db
238    }
239
240    /// Create the tables directly, for an application that would rather not
241    /// register [`CreateQueueTables`] in its migration registry.
242    pub async fn migrate(&self) -> Result<()> {
243        let schema = Schema::new(&self.db);
244        create_tables(&schema, &self.jobs, &self.failed).await?;
245        // The progress table too. `create_if_missing`, not `has_table` then
246        // `create`: this runs on every boot, and two workers booting at once
247        // would both see no table and one would fail — which is exactly what
248        // happened to the test suite the first time this ran.
249        schema.create_if_missing(JOB_PROGRESS_TABLE, define_job_progress_table).await
250    }
251
252    /// Drop the tables. Intended for tests and `queue:fresh`.
253    pub async fn drop_tables(&self) -> Result<()> {
254        let schema = Schema::new(&self.db);
255        schema.drop(&self.failed).await?;
256        schema.drop(&self.jobs).await
257    }
258
259    /// Take the next job, holding it against every other worker.
260    ///
261    /// `select ... for update skip locked` is the primitive this needs, and
262    /// nothing weaker will do:
263    ///
264    /// * A plain `select ... limit 1` followed by an `update` is a read-then-
265    ///   write race. Two workers read the same row, both update it, and the job
266    ///   runs twice — the one failure mode a queue may not have.
267    /// * `for update` alone fixes correctness by making the second worker block
268    ///   on the row lock, but it also serialises the pool: with ten workers, nine
269    ///   are queued behind the same row instead of taking the nine rows after it.
270    /// * `for update skip locked` locks the row this transaction picked and
271    ///   makes every other transaction *step over* it rather than wait. Ten
272    ///   workers take ten different rows in parallel, and no row is ever handed
273    ///   out twice, because the lock is held until this transaction commits the
274    ///   `reserved_at` that closes the row to everyone else.
275    ///
276    /// The select and the update are in one transaction for exactly that
277    /// reason: the lock only lasts as long as the transaction does, so the row
278    /// must be marked reserved before it is released.
279    /// The statement that claims one job, built for whichever database this
280    /// is talking to.
281    ///
282    /// Split out from [`DatabaseQueue::reserve`] so its shape can be asserted
283    /// without a server. Three things here are database-specific and none may
284    /// be written literally: the **placeholders** (`$1` on PostgreSQL, `?`
285    /// everywhere else), the **row-claiming clause** (a suffix on PostgreSQL
286    /// and MySQL, a table hint on SQL Server, nothing at all on SQLite), and
287    /// **`limit`**, which SQL Server spells `offset … fetch next … rows only`.
288    ///
289    /// Every one of those was once written the PostgreSQL way, which made this
290    /// the only package in the workspace that could not run on the other
291    /// databases the framework supports.
292    async fn reserve(&self, queue: &str) -> Result<Option<ReservedJob>> {
293        let now = unix_now();
294        let mut tx = self.db.begin().await?;
295
296        let dialect = self.db.dialect();
297        let sql = reserve_sql(&self.jobs_sql, dialect);
298        let row = tx.select_one(&sql, &[Value::from(queue), Value::from(now)]).await?;
299
300        let Some(row) = row else {
301            // Nothing to do, but the transaction still has to end — an open one
302            // would be discarded by the pool rather than reused.
303            tx.rollback().await?;
304            return Ok(None);
305        };
306
307        let id = row.get::<i64>("id")?;
308        let attempts = row.get::<i64>("attempts")? as u32 + 1;
309        let retry_after = Duration::from_secs(row.get::<i64>("retry_after")?.max(0) as u64);
310
311        tx.execute(
312            &format!(
313                "update {} set attempts = {}, reserved_at = {} where id = {}",
314                self.jobs_sql,
315                dialect.placeholder(1),
316                dialect.placeholder(2),
317                dialect.placeholder(3)
318            ),
319            &[Value::from(attempts), Value::from(now), Value::from(id)],
320        )
321        .await?;
322
323        tx.commit().await?;
324
325        // The payload column may hold a chain envelope; `with_stored_payload`
326        // unfolds it and leaves an ordinary payload alone.
327        let stored: Json = row.get::<Json>("payload")?;
328        let job = QueuedJob {
329            name: row.get::<String>("name")?,
330            payload: Json::Null,
331            queue: queue.to_string(),
332            max_tries: row.get::<i64>("max_tries")?.max(1) as u32,
333            retry_after,
334            delay: Duration::ZERO,
335            chain: None,
336            then: Vec::new(),
337        }
338        .with_stored_payload(stored);
339
340        Ok(Some(ReservedJob {
341            id: id.to_string(),
342            job,
343            attempts,
344        }))
345    }
346
347    /// Release jobs whose worker never came back.
348    ///
349    /// A worker that is killed between reserving and finishing leaves
350    /// `reserved_at` set forever. Once the job's own `retry_after` has passed
351    /// with no result, the job is assumed orphaned and goes back on the queue.
352    ///
353    /// Called only when the queue looks empty, so the common path — a worker
354    /// picking up one of many waiting jobs — pays nothing for it.
355    pub async fn reclaim_expired(&self, queue: &str) -> Result<u64> {
356        let now = unix_now();
357
358        self.db
359            .execute(
360                &format!(
361                    "update {} set reserved_at = null, available_at = {} \
362                     where queue = {} and reserved_at is not null and reserved_at + retry_after < {}",
363                    self.jobs_sql,
364                    self.db.dialect().placeholder(1),
365                    self.db.dialect().placeholder(2),
366                    self.db.dialect().placeholder(3)
367                ),
368                &[Value::from(now), Value::from(queue), Value::from(now)],
369            )
370            .await
371    }
372
373    fn row_to_failed(row: &rustlavel_db::Row) -> Result<FailedJob> {
374        Ok(FailedJob {
375            id: row.get::<i64>("id")?.to_string(),
376            name: row.get::<String>("name")?,
377            queue: row.get::<String>("queue")?,
378            payload: row.get::<Json>("payload")?,
379            attempts: row.get::<i64>("attempts")?.max(0) as u32,
380            error: row.get::<String>("error")?,
381            failed_at: row.get::<i64>("failed_at")?,
382        })
383    }
384
385    fn job_id(job: &ReservedJob) -> Result<i64> {
386        job.id.parse::<i64>().map_err(|_| {
387            Error::msg(format!(
388                "`{}` is not an id from the database queue. Was this job reserved by a \
389                 different driver?",
390                job.id
391            ))
392        })
393    }
394}
395
396impl Queue for DatabaseQueue {
397    fn driver(&self) -> &'static str {
398        "database"
399    }
400
401    fn push(&self, job: QueuedJob) -> BoxFuture<'_, Result<String>> {
402        Box::pin(async move {
403            let now = unix_now();
404            let id = self
405                .db
406                .table(&self.jobs)
407                .insert(
408                    &self.db,
409                    &[
410                        ("queue", Value::from(job.queue.as_str())),
411                        ("name", Value::from(job.name.as_str())),
412                        ("payload", Value::from(job.stored_payload())),
413                        ("attempts", Value::from(0)),
414                        ("max_tries", Value::from(job.max_tries)),
415                        ("retry_after", Value::from(job.retry_after.as_secs() as i64)),
416                        ("available_at", Value::from(now + job.delay.as_secs() as i64)),
417                        ("created_at", Value::from(now)),
418                    ],
419                )
420                .await?
421                .to_string();
422
423            record_pushed(self.driver(), &job, &id);
424            Ok(id)
425        })
426    }
427
428    fn pop<'a>(&'a self, queue: &'a str) -> BoxFuture<'a, Result<Option<ReservedJob>>> {
429        Box::pin(async move {
430            if let Some(job) = self.reserve(queue).await? {
431                return Ok(Some(job));
432            }
433
434            // Nothing was ready. Before reporting an empty queue, check whether
435            // a dead worker is sitting on something, and only re-select if it
436            // actually released anything.
437            if self.reclaim_expired(queue).await? > 0 {
438                return self.reserve(queue).await;
439            }
440
441            Ok(None)
442        })
443    }
444
445    fn size<'a>(&'a self, queue: &'a str) -> BoxFuture<'a, Result<u64>> {
446        Box::pin(async move {
447            let count = self.db.table(&self.jobs).filter("queue", queue).count(&self.db).await?;
448            Ok(count.max(0) as u64)
449        })
450    }
451
452    fn delete<'a>(&'a self, job: &'a ReservedJob) -> BoxFuture<'a, Result<()>> {
453        Box::pin(async move {
454            self.db
455                .table(&self.jobs)
456                .filter("id", DatabaseQueue::job_id(job)?)
457                .delete(&self.db)
458                .await?;
459            Ok(())
460        })
461    }
462
463    fn release<'a>(&'a self, job: &'a ReservedJob, delay: Duration) -> BoxFuture<'a, Result<()>> {
464        Box::pin(async move {
465            self.db
466                .table(&self.jobs)
467                .filter("id", DatabaseQueue::job_id(job)?)
468                .update(
469                    &self.db,
470                    &[
471                        ("reserved_at", Value::Null),
472                        ("available_at", Value::from(unix_now() + delay.as_secs() as i64)),
473                    ],
474                )
475                .await?;
476            Ok(())
477        })
478    }
479
480    fn fail<'a>(&'a self, job: &'a ReservedJob, error: &'a str) -> BoxFuture<'a, Result<()>> {
481        Box::pin(async move {
482            let id = DatabaseQueue::job_id(job)?;
483
484            // One transaction, so a job can never be in both tables or neither.
485            let mut tx = self.db.begin().await?;
486
487            tx.execute(
488                &format!(
489                    "insert into {} (queue, name, payload, attempts, error, failed_at) \
490                     values ({}, {}, {}, {}, {}, {})",
491                    self.failed_sql,
492                    self.db.dialect().placeholder(1),
493                    self.db.dialect().placeholder(2),
494                    self.db.dialect().placeholder(3),
495                    self.db.dialect().placeholder(4),
496                    self.db.dialect().placeholder(5),
497                    self.db.dialect().placeholder(6)
498                ),
499                &[
500                    Value::from(job.job.queue.as_str()),
501                    Value::from(job.job.name.as_str()),
502                    // The envelope, chain included, so a failed step's
503                    // remaining steps are visible in the dead-letter table
504                    // rather than silently gone.
505                    Value::from(job.job.stored_payload()),
506                    Value::from(job.attempts),
507                    Value::from(error),
508                    Value::from(unix_now()),
509                ],
510            )
511            .await?;
512
513            tx.execute(
514                &format!(
515                    "delete from {} where id = {}",
516                    self.jobs_sql,
517                    self.db.dialect().placeholder(1)
518                ),
519                &[Value::from(id)],
520            )
521            .await?;
522
523            tx.commit().await
524        })
525    }
526
527    fn failed_jobs(&self) -> BoxFuture<'_, Result<Vec<FailedJob>>> {
528        Box::pin(async move {
529            let rows = self
530                .db
531                .select(&format!("select * from {} order by id", self.failed_sql), &[])
532                .await?;
533            rows.iter().map(DatabaseQueue::row_to_failed).collect()
534        })
535    }
536
537    fn clear<'a>(&'a self, queue: &'a str) -> BoxFuture<'a, Result<u64>> {
538        Box::pin(async move {
539            self.db.table(&self.jobs).filter("queue", queue).delete(&self.db).await
540        })
541    }
542}
543
544#[cfg(test)]
545mod portability {
546    /// **No statement in this file may write a placeholder literally.**
547    ///
548    /// `$1` is PostgreSQL's spelling. MySQL and SQL Server use `?`, SQLite
549    /// uses `?` too, and a query carrying `$1` is a syntax error on three of
550    /// the four databases this framework claims to support. Every statement
551    /// here went through `dialect.placeholder(n)` — this fails if one stops.
552    ///
553    /// It was not hypothetical: until this guard existed, the queue was the
554    /// only package in the workspace that could not run anywhere but
555    /// PostgreSQL, and nothing said so.
556    #[test]
557    fn no_statement_writes_a_placeholder_literally() {
558        let source = include_str!("database.rs");
559        let offenders: Vec<(usize, &str)> = source
560            .lines()
561            .enumerate()
562            .map(|(number, line)| (number + 1, line))
563            // Everything *before* this module. `skip_while` was the bug: it
564            // kept only the guard itself, whose own strings name the very
565            // things it forbids, so it failed on its own explanation.
566            .take_while(|(_, line)| !line.contains("mod portability"))
567            .filter(|(_, line)| !line.trim_start().starts_with("///"))
568            .filter(|(_, line)| {
569                ["$1", "$2", "$3", "$4", "$5", "$6"].iter().any(|p| line.contains(p))
570            })
571            .collect();
572        assert!(
573            offenders.is_empty(),
574            "these lines write a PostgreSQL placeholder literally; use \
575             `dialect.placeholder(n)`: {offenders:?}"
576        );
577    }
578
579    /// The statement each database actually receives.
580    ///
581    /// Asserted as whole strings rather than by searching for fragments: a
582    /// clause in the wrong *position* is still present, and SQL Server's hint
583    /// belongs after the table while PostgreSQL's belongs at the end.
584    #[test]
585    fn the_claiming_statement_is_built_for_each_database() {
586        use rustlavel_db::dialect::{MySql, Postgres, Sqlite, SqlServer};
587
588        use rustlavel_db::dialect::quote_qualified;
589
590        let sql = |dialect: &dyn rustlavel_db::Dialect| {
591            super::reserve_sql(&quote_qualified(dialect, "jobs").unwrap(), dialect)
592        };
593
594        // **Byte for byte what the PostgreSQL-only version produced.** The
595        // literal it replaced was
596        //
597        //   "… from {} where queue = $1 and reserved_at is null and
598        //    available_at <= $2 order by id for update skip locked limit 1"
599        //
600        // so this assertion is the no-regression check as well as the shape
601        // check: PostgreSQL receives exactly the statement it always did, and
602        // if a refactor ever changes that, this says so without a server.
603        assert_eq!(
604            sql(&Postgres),
605            "select id, name, payload, attempts, max_tries, retry_after \
606             from \"jobs\" \
607             where queue = $1 and reserved_at is null and available_at <= $2 \
608             order by id for update skip locked limit 1"
609        );
610
611        assert_eq!(
612            sql(&MySql),
613            "select id, name, payload, attempts, max_tries, retry_after \
614             from `jobs` \
615             where queue = ? and reserved_at is null and available_at <= ? \
616             order by id for update skip locked limit 1"
617        );
618
619        // Brackets, `@P1` rather than `?`, the hint after the table, and no
620        // `limit` anywhere — four differences in one statement, which is the
621        // whole reason none of them may be written by hand.
622        assert_eq!(
623            sql(&SqlServer),
624            "select id, name, payload, attempts, max_tries, retry_after \
625             from [jobs] with (updlock, readpast, rowlock) \
626             where queue = @P1 and reserved_at is null and available_at <= @P2 \
627             order by id offset 0 rows fetch next 1 rows only"
628        );
629
630        // No locking clause at all: SQLite's `begin immediate` is what stops
631        // two workers reading the same row.
632        assert_eq!(
633            sql(&Sqlite),
634            "select id, name, payload, attempts, max_tries, retry_after \
635             from \"jobs\" \
636             where queue = ? and reserved_at is null and available_at <= ? \
637             order by id limit 1"
638        );
639    }
640
641    /// The row-claiming clause is the dialect's too, for the same reason:
642    /// SQL Server spells it as a table hint and SQLite has no such thing.
643    #[test]
644    fn the_row_lock_is_asked_of_the_dialect_not_written_here() {
645        let source = include_str!("database.rs");
646        let written_literally = source
647            .lines()
648            .take_while(|line| !line.contains("mod portability"))
649            .any(|line| {
650                !line.trim_start().starts_with("///") && line.contains("for update skip locked")
651            });
652        assert!(
653            !written_literally,
654            "`for update skip locked` is written into a statement here; ask \
655             `dialect.skip_locked()` instead — SQL Server needs a table hint"
656        );
657    }
658}
659
660#[cfg(test)]
661mod tests {
662    use super::*;
663    use rustlavel_db::Migration;
664
665    #[test]
666    fn table_names_are_validated_once_at_construction() {
667        let db = Database::lazy(rustlavel_db::DatabaseConfig::default())
668            .expect("the default configuration names a driver that exists");
669
670        assert!(DatabaseQueue::with_tables(db.clone(), "app_jobs", "app_failed").is_ok());
671
672        let error = match DatabaseQueue::with_tables(db, "jobs; drop table users", "failed") {
673            Err(error) => error.to_string(),
674            Ok(_) => panic!("an injected table name should have been rejected"),
675        };
676        assert!(error.contains("not a valid SQL identifier"), "{error}");
677    }
678
679    #[test]
680    fn the_jobs_table_has_the_columns_the_driver_reads() {
681        // The tables assert PostgreSQL's spelling; the dialect layer covers the
682        // other databases.
683        let jobs = rustlavel_db::schema::create_statements(
684            &rustlavel_db::dialect::Postgres,
685            JOBS_TABLE,
686            define_jobs_table,
687        )
688        .unwrap();
689
690        for column in [
691            r#""id" bigserial primary key"#,
692            r#""queue" varchar(255) not null"#,
693            r#""name" varchar(255) not null"#,
694            r#""payload" jsonb not null"#,
695            r#""attempts" integer not null default 0"#,
696            r#""max_tries" integer not null default 3"#,
697            r#""retry_after" integer not null default 60"#,
698            r#""available_at" bigint not null"#,
699            r#""created_at" bigint not null"#,
700        ] {
701            assert!(jobs[0].contains(column), "expected {column} in:\n{}", jobs[0]);
702        }
703
704        // Nullable on purpose: null is what "nobody holds this job" means, and
705        // it is what the reservation query filters on.
706        assert!(jobs[0].contains(r#""reserved_at" bigint,"#), "{}", jobs[0]);
707        assert!(!jobs[0].contains(r#""reserved_at" bigint not null"#), "{}", jobs[0]);
708
709        assert!(
710            jobs[1].contains(r#"on "jobs" ("queue", "reserved_at", "available_at")"#),
711            "the reservation query needs a covering index:\n{}",
712            jobs[1]
713        );
714    }
715
716    #[test]
717    fn the_failed_jobs_table_keeps_everything_needed_to_diagnose_a_job() {
718        let failed =
719            rustlavel_db::schema::create_statements(
720                &rustlavel_db::dialect::Postgres,
721                FAILED_JOBS_TABLE,
722                define_failed_jobs_table,
723            )
724                .unwrap();
725
726        for column in [
727            r#""queue" varchar(255) not null"#,
728            r#""name" varchar(255) not null"#,
729            r#""payload" jsonb not null"#,
730            r#""attempts" integer not null"#,
731            r#""error" text not null"#,
732            r#""failed_at" bigint not null"#,
733        ] {
734            assert!(failed[0].contains(column), "expected {column} in:\n{}", failed[0]);
735        }
736    }
737
738    #[test]
739    fn the_migration_is_registered_under_a_sortable_name() {
740        assert_eq!(CreateQueueTables.name(), "2026_08_29_000001_create_queue_tables");
741    }
742
743    #[test]
744    fn an_id_from_another_driver_is_rejected_rather_than_silently_ignored() {
745        let job = ReservedJob {
746            id: "memory-7".to_string(),
747            job: QueuedJob::new("x", Json::Null),
748            attempts: 1,
749        };
750
751        let error = DatabaseQueue::job_id(&job).unwrap_err().to_string();
752        assert!(error.contains("not an id from the database queue"), "{error}");
753    }
754}