1use 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
19pub const JOBS_TABLE: &str = "jobs";
21pub const FAILED_JOBS_TABLE: &str = "failed_jobs";
22
23pub 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
33pub fn define_jobs_table(t: &mut Table) {
36 t.id();
37 t.string("queue");
38 t.string("name");
39 t.json("payload");
40 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 t.big_integer("reserved_at").nullable();
49 t.big_integer("created_at");
50 t.index(&["queue", "reserved_at", "available_at"]);
53}
54
55pub 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
78pub fn define_job_progress_table(t: &mut Table) {
81 t.id();
82 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
91rustlavel_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#[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 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#[derive(Clone)]
179pub struct DatabaseQueue {
180 db: Database,
181 jobs: String,
182 failed: String,
183 jobs_sql: String,
185 failed_sql: String,
186}
187
188const RECLAIM_BATCH: i64 = 500;
195
196fn 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
213fn reserve_sql(jobs_sql: &str, dialect: &dyn rustlavel_db::Dialect) -> String {
227 let (hint, lock) = dialect.skip_locked();
228 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 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 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 pub async fn migrate(&self) -> Result<()> {
271 let schema = Schema::new(&self.db);
272 create_tables(&schema, &self.jobs, &self.failed).await?;
273 schema.create_if_missing(JOB_PROGRESS_TABLE, define_job_progress_table).await
278 }
279
280 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 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 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 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 pub async fn reclaim_expired(&self, queue: &str) -> Result<u64> {
384 let now = unix_now();
385 let dialect = self.db.dialect();
386
387 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 ¶ms,
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 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 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 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 #[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 .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 #[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("e_qualified(dialect, "jobs").unwrap(), dialect)
650 };
651
652 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 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 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 #[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("e_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 #[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 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 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 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}