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
188fn 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 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 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 pub async fn migrate(&self) -> Result<()> {
243 let schema = Schema::new(&self.db);
244 create_tables(&schema, &self.jobs, &self.failed).await?;
245 schema.create_if_missing(JOB_PROGRESS_TABLE, define_job_progress_table).await
250 }
251
252 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 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 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 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 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 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 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 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 #[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 .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 #[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("e_qualified(dialect, "jobs").unwrap(), dialect)
592 };
593
594 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 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 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 #[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 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 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}