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/// How many orphans one reclaim releases.
189///
190/// Bounded because the release names each row: SQL Server refuses a statement
191/// with more than 2,100 parameters, and a crash that orphaned thousands of jobs
192/// would otherwise produce exactly that. Reclaim runs whenever the queue looks
193/// empty, so a larger backlog is cleared over a few polls rather than in one.
194const RECLAIM_BATCH: i64 = 500;
195
196/// The statement that finds orphaned reservations, built for this database.
197///
198/// The same three dialect pieces as [`reserve_sql`], in the same order —
199/// `limit` before the lock clause, which MySQL insists on — so the two locking
200/// reads in the queue cannot disagree about how a row is claimed.
201fn reclaim_sql(jobs_sql: &str, dialect: &dyn rustlavel_db::Dialect) -> String {
202    let (hint, lock) = dialect.skip_locked();
203    format!(
204        "select id from {jobs_sql}{hint} \
205         where queue = {} and reserved_at is not null and reserved_at + retry_after < {} \
206         order by id{}{lock}",
207        dialect.placeholder(1),
208        dialect.placeholder(2),
209        dialect.limit_offset(Some(RECLAIM_BATCH), None, true)
210    )
211}
212
213/// The statement that claims one job, built for whichever database this is
214/// talking to.
215///
216/// A free function so its shape can be asserted without opening a database.
217/// Four things here are database-specific and none may be written literally:
218/// the **table name's quoting**, the **placeholders** (`$1` on PostgreSQL,
219/// `?` elsewhere), the **row-claiming clause** (a suffix on PostgreSQL and
220/// MySQL, a table hint on SQL Server, nothing at all on SQLite), and
221/// **`limit`**, which SQL Server spells `offset … fetch next … rows only`.
222///
223/// Every one of those was once written the PostgreSQL way, which made this
224/// the only package in the workspace that could not run on the other
225/// databases the framework claims to support.
226fn reserve_sql(jobs_sql: &str, dialect: &dyn rustlavel_db::Dialect) -> String {
227    let (hint, lock) = dialect.skip_locked();
228    // `limit` before the lock clause. MySQL requires that order and rejects
229    // the other with a syntax error; PostgreSQL accepts both, which is how the
230    // wrong order survived — it was only ever run against PostgreSQL.
231    format!(
232        "select id, name, payload, attempts, max_tries, retry_after \
233         from {jobs_sql}{hint} \
234         where queue = {} and reserved_at is null and available_at <= {} \
235         order by id{}{lock}",
236        dialect.placeholder(1),
237        dialect.placeholder(2),
238        dialect.limit_offset(Some(1), None, true)
239    )
240}
241
242impl DatabaseQueue {
243    /// Use the conventional `jobs` and `failed_jobs` tables.
244    pub fn new(db: Database) -> Self {
245        DatabaseQueue::with_tables(db, JOBS_TABLE, FAILED_JOBS_TABLE)
246            .expect("the built-in table names are valid identifiers")
247    }
248
249    /// Use table names of your own. Validated and quoted here, once, for the
250    /// database this queue is talking to — `"jobs"` on PostgreSQL and SQLite,
251    /// `` `jobs` `` on MySQL, `[jobs]` on SQL Server. The shared
252    /// `quote_identifier` helper always writes double quotes, which MySQL
253    /// reads as a string literal rather than a table.
254    pub fn with_tables(db: Database, jobs: &str, failed: &str) -> Result<Self> {
255        Ok(DatabaseQueue {
256            jobs_sql: quote_qualified(db.dialect(), jobs)?,
257            failed_sql: quote_qualified(db.dialect(), failed)?,
258            jobs: jobs.to_string(),
259            failed: failed.to_string(),
260            db,
261        })
262    }
263
264    pub fn database(&self) -> &Database {
265        &self.db
266    }
267
268    /// Create the tables directly, for an application that would rather not
269    /// register [`CreateQueueTables`] in its migration registry.
270    pub async fn migrate(&self) -> Result<()> {
271        let schema = Schema::new(&self.db);
272        create_tables(&schema, &self.jobs, &self.failed).await?;
273        // The progress table too. `create_if_missing`, not `has_table` then
274        // `create`: this runs on every boot, and two workers booting at once
275        // would both see no table and one would fail — which is exactly what
276        // happened to the test suite the first time this ran.
277        schema.create_if_missing(JOB_PROGRESS_TABLE, define_job_progress_table).await
278    }
279
280    /// Drop the tables. Intended for tests and `queue:fresh`.
281    pub async fn drop_tables(&self) -> Result<()> {
282        let schema = Schema::new(&self.db);
283        schema.drop(&self.failed).await?;
284        schema.drop(&self.jobs).await
285    }
286
287    /// Take the next job, holding it against every other worker.
288    ///
289    /// `select ... for update skip locked` is the primitive this needs, and
290    /// nothing weaker will do:
291    ///
292    /// * A plain `select ... limit 1` followed by an `update` is a read-then-
293    ///   write race. Two workers read the same row, both update it, and the job
294    ///   runs twice — the one failure mode a queue may not have.
295    /// * `for update` alone fixes correctness by making the second worker block
296    ///   on the row lock, but it also serialises the pool: with ten workers, nine
297    ///   are queued behind the same row instead of taking the nine rows after it.
298    /// * `for update skip locked` locks the row this transaction picked and
299    ///   makes every other transaction *step over* it rather than wait. Ten
300    ///   workers take ten different rows in parallel, and no row is ever handed
301    ///   out twice, because the lock is held until this transaction commits the
302    ///   `reserved_at` that closes the row to everyone else.
303    ///
304    /// The select and the update are in one transaction for exactly that
305    /// reason: the lock only lasts as long as the transaction does, so the row
306    /// must be marked reserved before it is released.
307    /// The statement that claims one job, built for whichever database this
308    /// is talking to.
309    ///
310    /// Split out from [`DatabaseQueue::reserve`] so its shape can be asserted
311    /// without a server. Three things here are database-specific and none may
312    /// be written literally: the **placeholders** (`$1` on PostgreSQL, `?`
313    /// everywhere else), the **row-claiming clause** (a suffix on PostgreSQL
314    /// and MySQL, a table hint on SQL Server, nothing at all on SQLite), and
315    /// **`limit`**, which SQL Server spells `offset … fetch next … rows only`.
316    ///
317    /// Every one of those was once written the PostgreSQL way, which made this
318    /// the only package in the workspace that could not run on the other
319    /// databases the framework supports.
320    async fn reserve(&self, queue: &str) -> Result<Option<ReservedJob>> {
321        let now = unix_now();
322        let mut tx = self.db.begin().await?;
323
324        let dialect = self.db.dialect();
325        let sql = reserve_sql(&self.jobs_sql, dialect);
326        let row = tx.select_one(&sql, &[Value::from(queue), Value::from(now)]).await?;
327
328        let Some(row) = row else {
329            // Nothing to do, but the transaction still has to end — an open one
330            // would be discarded by the pool rather than reused.
331            tx.rollback().await?;
332            return Ok(None);
333        };
334
335        let id = row.get::<i64>("id")?;
336        let attempts = row.get::<i64>("attempts")? as u32 + 1;
337        let retry_after = Duration::from_secs(row.get::<i64>("retry_after")?.max(0) as u64);
338
339        tx.execute(
340            &format!(
341                "update {} set attempts = {}, reserved_at = {} where id = {}",
342                self.jobs_sql,
343                dialect.placeholder(1),
344                dialect.placeholder(2),
345                dialect.placeholder(3)
346            ),
347            &[Value::from(attempts), Value::from(now), Value::from(id)],
348        )
349        .await?;
350
351        tx.commit().await?;
352
353        // The payload column may hold a chain envelope; `with_stored_payload`
354        // unfolds it and leaves an ordinary payload alone.
355        let stored: Json = row.get::<Json>("payload")?;
356        let job = QueuedJob {
357            name: row.get::<String>("name")?,
358            payload: Json::Null,
359            queue: queue.to_string(),
360            max_tries: row.get::<i64>("max_tries")?.max(1) as u32,
361            retry_after,
362            delay: Duration::ZERO,
363            chain: None,
364            then: Vec::new(),
365        }
366        .with_stored_payload(stored);
367
368        Ok(Some(ReservedJob {
369            id: id.to_string(),
370            job,
371            attempts,
372        }))
373    }
374
375    /// Release jobs whose worker never came back.
376    ///
377    /// A worker that is killed between reserving and finishing leaves
378    /// `reserved_at` set forever. Once the job's own `retry_after` has passed
379    /// with no result, the job is assumed orphaned and goes back on the queue.
380    ///
381    /// Called only when the queue looks empty, so the common path — a worker
382    /// picking up one of many waiting jobs — pays nothing for it.
383    pub async fn reclaim_expired(&self, queue: &str) -> Result<u64> {
384        let now = unix_now();
385        let dialect = self.db.dialect();
386
387        // **Two statements, not one, and the first skips locked rows.** This
388        // was a single `update … where reserved_at + retry_after < ?`, and on
389        // MySQL it deadlocked against the workers it runs beside: an InnoDB
390        // update locks every row it *scans*, not just the ones it changes,
391        // while each worker holds its own row under `for update skip locked`.
392        // Two transactions each holding what the other wanted — MySQL killed
393        // one with error 1213, seven runs in fifteen of the queue's race test.
394        //
395        // Selecting with the same skip-locked clause the reservation uses
396        // means reclaim steps over a row somebody holds instead of waiting for
397        // it, and every locking read in the queue now does that. A transaction
398        // that never waits cannot be part of a cycle. A row skipped here is
399        // not an orphan anyway: somebody holds it right now.
400        let mut tx = self.db.begin().await?;
401        let rows = tx
402            .select(&reclaim_sql(&self.jobs_sql, dialect), &[Value::from(queue), Value::from(now)])
403            .await?;
404        let ids: Vec<i64> =
405            rows.iter().map(|row| row.get::<i64>("id")).collect::<Result<_>>()?;
406        if ids.is_empty() {
407            tx.rollback().await?;
408            return Ok(0);
409        }
410
411        let placeholders: Vec<String> =
412            (0..ids.len()).map(|n| dialect.placeholder(n + 2)).collect();
413        let mut params = Vec::with_capacity(ids.len() + 1);
414        params.push(Value::from(now));
415        params.extend(ids.iter().map(|id| Value::from(*id)));
416        let changed = tx
417            .execute(
418                &format!(
419                    "update {} set reserved_at = null, available_at = {} where id in ({})",
420                    self.jobs_sql,
421                    dialect.placeholder(1),
422                    placeholders.join(", ")
423                ),
424                &params,
425            )
426            .await?;
427        tx.commit().await?;
428        Ok(changed)
429    }
430
431    fn row_to_failed(row: &rustlavel_db::Row) -> Result<FailedJob> {
432        Ok(FailedJob {
433            id: row.get::<i64>("id")?.to_string(),
434            name: row.get::<String>("name")?,
435            queue: row.get::<String>("queue")?,
436            payload: row.get::<Json>("payload")?,
437            attempts: row.get::<i64>("attempts")?.max(0) as u32,
438            error: row.get::<String>("error")?,
439            failed_at: row.get::<i64>("failed_at")?,
440        })
441    }
442
443    fn job_id(job: &ReservedJob) -> Result<i64> {
444        job.id.parse::<i64>().map_err(|_| {
445            Error::msg(format!(
446                "`{}` is not an id from the database queue. Was this job reserved by a \
447                 different driver?",
448                job.id
449            ))
450        })
451    }
452}
453
454impl Queue for DatabaseQueue {
455    fn driver(&self) -> &'static str {
456        "database"
457    }
458
459    fn push(&self, job: QueuedJob) -> BoxFuture<'_, Result<String>> {
460        Box::pin(async move {
461            let now = unix_now();
462            let id = self
463                .db
464                .table(&self.jobs)
465                .insert(
466                    &self.db,
467                    &[
468                        ("queue", Value::from(job.queue.as_str())),
469                        ("name", Value::from(job.name.as_str())),
470                        ("payload", Value::from(job.stored_payload())),
471                        ("attempts", Value::from(0)),
472                        ("max_tries", Value::from(job.max_tries)),
473                        ("retry_after", Value::from(job.retry_after.as_secs() as i64)),
474                        ("available_at", Value::from(now + job.delay.as_secs() as i64)),
475                        ("created_at", Value::from(now)),
476                    ],
477                )
478                .await?
479                .to_string();
480
481            record_pushed(self.driver(), &job, &id);
482            Ok(id)
483        })
484    }
485
486    fn pop<'a>(&'a self, queue: &'a str) -> BoxFuture<'a, Result<Option<ReservedJob>>> {
487        Box::pin(async move {
488            if let Some(job) = self.reserve(queue).await? {
489                return Ok(Some(job));
490            }
491
492            // Nothing was ready. Before reporting an empty queue, check whether
493            // a dead worker is sitting on something, and only re-select if it
494            // actually released anything.
495            if self.reclaim_expired(queue).await? > 0 {
496                return self.reserve(queue).await;
497            }
498
499            Ok(None)
500        })
501    }
502
503    fn size<'a>(&'a self, queue: &'a str) -> BoxFuture<'a, Result<u64>> {
504        Box::pin(async move {
505            let count = self.db.table(&self.jobs).filter("queue", queue).count(&self.db).await?;
506            Ok(count.max(0) as u64)
507        })
508    }
509
510    fn delete<'a>(&'a self, job: &'a ReservedJob) -> BoxFuture<'a, Result<()>> {
511        Box::pin(async move {
512            self.db
513                .table(&self.jobs)
514                .filter("id", DatabaseQueue::job_id(job)?)
515                .delete(&self.db)
516                .await?;
517            Ok(())
518        })
519    }
520
521    fn release<'a>(&'a self, job: &'a ReservedJob, delay: Duration) -> BoxFuture<'a, Result<()>> {
522        Box::pin(async move {
523            self.db
524                .table(&self.jobs)
525                .filter("id", DatabaseQueue::job_id(job)?)
526                .update(
527                    &self.db,
528                    &[
529                        ("reserved_at", Value::Null),
530                        ("available_at", Value::from(unix_now() + delay.as_secs() as i64)),
531                    ],
532                )
533                .await?;
534            Ok(())
535        })
536    }
537
538    fn fail<'a>(&'a self, job: &'a ReservedJob, error: &'a str) -> BoxFuture<'a, Result<()>> {
539        Box::pin(async move {
540            let id = DatabaseQueue::job_id(job)?;
541
542            // One transaction, so a job can never be in both tables or neither.
543            let mut tx = self.db.begin().await?;
544
545            tx.execute(
546                &format!(
547                    "insert into {} (queue, name, payload, attempts, error, failed_at) \
548                     values ({}, {}, {}, {}, {}, {})",
549                    self.failed_sql,
550                    self.db.dialect().placeholder(1),
551                    self.db.dialect().placeholder(2),
552                    self.db.dialect().placeholder(3),
553                    self.db.dialect().placeholder(4),
554                    self.db.dialect().placeholder(5),
555                    self.db.dialect().placeholder(6)
556                ),
557                &[
558                    Value::from(job.job.queue.as_str()),
559                    Value::from(job.job.name.as_str()),
560                    // The envelope, chain included, so a failed step's
561                    // remaining steps are visible in the dead-letter table
562                    // rather than silently gone.
563                    Value::from(job.job.stored_payload()),
564                    Value::from(job.attempts),
565                    Value::from(error),
566                    Value::from(unix_now()),
567                ],
568            )
569            .await?;
570
571            tx.execute(
572                &format!(
573                    "delete from {} where id = {}",
574                    self.jobs_sql,
575                    self.db.dialect().placeholder(1)
576                ),
577                &[Value::from(id)],
578            )
579            .await?;
580
581            tx.commit().await
582        })
583    }
584
585    fn failed_jobs(&self) -> BoxFuture<'_, Result<Vec<FailedJob>>> {
586        Box::pin(async move {
587            let rows = self
588                .db
589                .select(&format!("select * from {} order by id", self.failed_sql), &[])
590                .await?;
591            rows.iter().map(DatabaseQueue::row_to_failed).collect()
592        })
593    }
594
595    fn clear<'a>(&'a self, queue: &'a str) -> BoxFuture<'a, Result<u64>> {
596        Box::pin(async move {
597            self.db.table(&self.jobs).filter("queue", queue).delete(&self.db).await
598        })
599    }
600}
601
602#[cfg(test)]
603mod portability {
604    /// **No statement in this file may write a placeholder literally.**
605    ///
606    /// `$1` is PostgreSQL's spelling. MySQL and SQL Server use `?`, SQLite
607    /// uses `?` too, and a query carrying `$1` is a syntax error on three of
608    /// the four databases this framework claims to support. Every statement
609    /// here went through `dialect.placeholder(n)` — this fails if one stops.
610    ///
611    /// It was not hypothetical: until this guard existed, the queue was the
612    /// only package in the workspace that could not run anywhere but
613    /// PostgreSQL, and nothing said so.
614    #[test]
615    fn no_statement_writes_a_placeholder_literally() {
616        let source = include_str!("database.rs");
617        let offenders: Vec<(usize, &str)> = source
618            .lines()
619            .enumerate()
620            .map(|(number, line)| (number + 1, line))
621            // Everything *before* this module. `skip_while` was the bug: it
622            // kept only the guard itself, whose own strings name the very
623            // things it forbids, so it failed on its own explanation.
624            .take_while(|(_, line)| !line.contains("mod portability"))
625            .filter(|(_, line)| !line.trim_start().starts_with("///"))
626            .filter(|(_, line)| {
627                ["$1", "$2", "$3", "$4", "$5", "$6"].iter().any(|p| line.contains(p))
628            })
629            .collect();
630        assert!(
631            offenders.is_empty(),
632            "these lines write a PostgreSQL placeholder literally; use \
633             `dialect.placeholder(n)`: {offenders:?}"
634        );
635    }
636
637    /// The statement each database actually receives.
638    ///
639    /// Asserted as whole strings rather than by searching for fragments: a
640    /// clause in the wrong *position* is still present, and SQL Server's hint
641    /// belongs after the table while PostgreSQL's belongs at the end.
642    #[test]
643    fn the_claiming_statement_is_built_for_each_database() {
644        use rustlavel_db::dialect::{MySql, Postgres, Sqlite, SqlServer};
645
646        use rustlavel_db::dialect::quote_qualified;
647
648        let sql = |dialect: &dyn rustlavel_db::Dialect| {
649            super::reserve_sql(&quote_qualified(dialect, "jobs").unwrap(), dialect)
650        };
651
652        // **These strings are only as right as the belief they encode.** They
653        // once asserted `… for update skip locked limit 1` for PostgreSQL *and*
654        // MySQL, and passed — because that was what the code produced and what
655        // its author thought correct. MySQL rejects it: `limit` must come
656        // first. PostgreSQL accepts either order, so a suite run only against
657        // PostgreSQL could never have said so.
658        //
659        // What caught it was running the queue's integration tests against a
660        // live MySQL. So these assertions pin the shape, and the live suites
661        // (`tests/postgres.rs`, pointed at each database in turn) prove it is
662        // accepted. Neither is enough alone.
663        assert_eq!(
664            sql(&Postgres),
665            "select id, name, payload, attempts, max_tries, retry_after \
666             from \"jobs\" \
667             where queue = $1 and reserved_at is null and available_at <= $2 \
668             order by id limit 1 for update skip locked"
669        );
670
671        assert_eq!(
672            sql(&MySql),
673            "select id, name, payload, attempts, max_tries, retry_after \
674             from `jobs` \
675             where queue = ? and reserved_at is null and available_at <= ? \
676             order by id limit 1 for update skip locked"
677        );
678
679        // Brackets, `@P1` rather than `?`, the hint after the table, and no
680        // `limit` anywhere — four differences in one statement, which is the
681        // whole reason none of them may be written by hand.
682        assert_eq!(
683            sql(&SqlServer),
684            "select id, name, payload, attempts, max_tries, retry_after \
685             from [jobs] with (updlock, readpast, rowlock) \
686             where queue = @P1 and reserved_at is null and available_at <= @P2 \
687             order by id offset 0 rows fetch next 1 rows only"
688        );
689
690        // No locking clause at all: SQLite's `begin immediate` is what stops
691        // two workers reading the same row.
692        assert_eq!(
693            sql(&Sqlite),
694            "select id, name, payload, attempts, max_tries, retry_after \
695             from \"jobs\" \
696             where queue = ? and reserved_at is null and available_at <= ? \
697             order by id limit 1"
698        );
699    }
700
701    /// The reclaim statement locks rows the same way the reservation does —
702    /// that is the whole fix: both locking reads skip what somebody holds, so
703    /// neither can wait on the other. These pin the shape; the deadlock itself
704    /// is only visible against a live MySQL (`many_workers_racing…`, which
705    /// failed seven runs in fifteen before this).
706    #[test]
707    fn the_reclaim_select_claims_rows_like_the_reservation_does() {
708        use rustlavel_db::dialect::{MySql, Postgres, SqlServer, Sqlite, quote_qualified};
709
710        let sql = |dialect: &dyn rustlavel_db::Dialect| {
711            super::reclaim_sql(&quote_qualified(dialect, "jobs").unwrap(), dialect)
712        };
713
714        assert_eq!(
715            sql(&Postgres),
716            "select id from \"jobs\" \
717             where queue = $1 and reserved_at is not null and reserved_at + retry_after < $2 \
718             order by id limit 500 for update skip locked"
719        );
720        assert_eq!(
721            sql(&MySql),
722            "select id from `jobs` \
723             where queue = ? and reserved_at is not null and reserved_at + retry_after < ? \
724             order by id limit 500 for update skip locked"
725        );
726        assert_eq!(
727            sql(&SqlServer),
728            "select id from [jobs] with (updlock, readpast, rowlock) \
729             where queue = @P1 and reserved_at is not null and reserved_at + retry_after < @P2 \
730             order by id offset 0 rows fetch next 500 rows only"
731        );
732        assert_eq!(
733            sql(&Sqlite),
734            "select id from \"jobs\" \
735             where queue = ? and reserved_at is not null and reserved_at + retry_after < ? \
736             order by id limit 500"
737        );
738    }
739
740    /// The row-claiming clause is the dialect's too, for the same reason:
741    /// SQL Server spells it as a table hint and SQLite has no such thing.
742    #[test]
743    fn the_row_lock_is_asked_of_the_dialect_not_written_here() {
744        let source = include_str!("database.rs");
745        let written_literally = source
746            .lines()
747            .take_while(|line| !line.contains("mod portability"))
748            .any(|line| {
749                // Comments of either kind may name the phrase — explaining why
750                // it is not written here is the point of half of them. Only a
751                // line of code can be a statement.
752                let code = line.trim_start();
753                !code.starts_with("//") && line.contains("for update skip locked")
754            });
755        assert!(
756            !written_literally,
757            "`for update skip locked` is written into a statement here; ask \
758             `dialect.skip_locked()` instead — SQL Server needs a table hint"
759        );
760    }
761}
762
763#[cfg(test)]
764mod tests {
765    use super::*;
766    use rustlavel_db::Migration;
767
768    #[test]
769    fn table_names_are_validated_once_at_construction() {
770        let db = Database::lazy(rustlavel_db::DatabaseConfig::default())
771            .expect("the default configuration names a driver that exists");
772
773        assert!(DatabaseQueue::with_tables(db.clone(), "app_jobs", "app_failed").is_ok());
774
775        let error = match DatabaseQueue::with_tables(db, "jobs; drop table users", "failed") {
776            Err(error) => error.to_string(),
777            Ok(_) => panic!("an injected table name should have been rejected"),
778        };
779        assert!(error.contains("not a valid SQL identifier"), "{error}");
780    }
781
782    #[test]
783    fn the_jobs_table_has_the_columns_the_driver_reads() {
784        // The tables assert PostgreSQL's spelling; the dialect layer covers the
785        // other databases.
786        let jobs = rustlavel_db::schema::create_statements(
787            &rustlavel_db::dialect::Postgres,
788            JOBS_TABLE,
789            define_jobs_table,
790        )
791        .unwrap();
792
793        for column in [
794            r#""id" bigserial primary key"#,
795            r#""queue" varchar(255) not null"#,
796            r#""name" varchar(255) not null"#,
797            r#""payload" jsonb not null"#,
798            r#""attempts" integer not null default 0"#,
799            r#""max_tries" integer not null default 3"#,
800            r#""retry_after" integer not null default 60"#,
801            r#""available_at" bigint not null"#,
802            r#""created_at" bigint not null"#,
803        ] {
804            assert!(jobs[0].contains(column), "expected {column} in:\n{}", jobs[0]);
805        }
806
807        // Nullable on purpose: null is what "nobody holds this job" means, and
808        // it is what the reservation query filters on.
809        assert!(jobs[0].contains(r#""reserved_at" bigint,"#), "{}", jobs[0]);
810        assert!(!jobs[0].contains(r#""reserved_at" bigint not null"#), "{}", jobs[0]);
811
812        assert!(
813            jobs[1].contains(r#"on "jobs" ("queue", "reserved_at", "available_at")"#),
814            "the reservation query needs a covering index:\n{}",
815            jobs[1]
816        );
817    }
818
819    #[test]
820    fn the_failed_jobs_table_keeps_everything_needed_to_diagnose_a_job() {
821        let failed =
822            rustlavel_db::schema::create_statements(
823                &rustlavel_db::dialect::Postgres,
824                FAILED_JOBS_TABLE,
825                define_failed_jobs_table,
826            )
827                .unwrap();
828
829        for column in [
830            r#""queue" varchar(255) not null"#,
831            r#""name" varchar(255) not null"#,
832            r#""payload" jsonb not null"#,
833            r#""attempts" integer not null"#,
834            r#""error" text not null"#,
835            r#""failed_at" bigint not null"#,
836        ] {
837            assert!(failed[0].contains(column), "expected {column} in:\n{}", failed[0]);
838        }
839    }
840
841    #[test]
842    fn the_migration_is_registered_under_a_sortable_name() {
843        assert_eq!(CreateQueueTables.name(), "2026_08_29_000001_create_queue_tables");
844    }
845
846    #[test]
847    fn an_id_from_another_driver_is_rejected_rather_than_silently_ignored() {
848        let job = ReservedJob {
849            id: "memory-7".to_string(),
850            job: QueuedJob::new("x", Json::Null),
851            attempts: 1,
852        };
853
854        let error = DatabaseQueue::job_id(&job).unwrap_err().to_string();
855        assert!(error.contains("not an id from the database queue"), "{error}");
856    }
857}