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::{Database, Value, quote_identifier};
16use std::time::Duration;
17
18pub const JOBS_TABLE: &str = "jobs";
20pub const FAILED_JOBS_TABLE: &str = "failed_jobs";
21
22pub async fn create_tables(schema: &Schema<'_>, jobs: &str, failed: &str) -> Result<()> {
27 schema.create(jobs, define_jobs_table).await?;
28 schema.create(failed, define_failed_jobs_table).await?;
29 Ok(())
30}
31
32pub fn define_jobs_table(t: &mut Table) {
35 t.id();
36 t.string("queue");
37 t.string("name");
38 t.json("payload");
39 t.integer("attempts").default_int(0);
42 t.integer("max_tries").default_int(crate::job::DEFAULT_TRIES as i64);
43 t.integer("retry_after").default_int(60);
44 t.big_integer("available_at");
45 t.big_integer("reserved_at").nullable();
48 t.big_integer("created_at");
49 t.index(&["queue", "reserved_at", "available_at"]);
52}
53
54pub fn define_failed_jobs_table(t: &mut Table) {
56 t.id();
57 t.string("queue");
58 t.string("name");
59 t.json("payload");
60 t.integer("attempts");
61 t.text("error");
62 t.big_integer("failed_at");
63}
64
65rustlavel_db::migration!(
66 CreateQueueTables,
67 "2026_08_29_000001_create_queue_tables",
68 up: |schema| { crate::database::create_tables(schema, JOBS_TABLE, FAILED_JOBS_TABLE).await },
69 down: |schema| {
70 schema.drop(FAILED_JOBS_TABLE).await?;
71 schema.drop(JOBS_TABLE).await
72 },
73);
74
75pub const JOB_PROGRESS_TABLE: &str = "job_progress";
76
77pub fn define_job_progress_table(t: &mut Table) {
80 t.id();
81 t.string("key").unique();
83 t.integer("percent");
84 t.string("stage");
85 t.big_integer("updated_at");
86 t.boolean("finished").default_bool(false);
87 t.text("failed").nullable();
88}
89
90rustlavel_db::migration!(
93 CreateJobProgressTable,
94 "2026_09_17_000001_create_job_progress_table",
95 up: |schema| { schema.create(JOB_PROGRESS_TABLE, crate::database::define_job_progress_table).await },
96 down: |schema| { schema.drop(JOB_PROGRESS_TABLE).await },
97);
98
99#[derive(Clone)]
101pub struct DatabaseProgress {
102 db: Database,
103 table: String,
104}
105
106impl DatabaseProgress {
107 pub fn new(db: Database) -> DatabaseProgress {
108 DatabaseProgress { db, table: JOB_PROGRESS_TABLE.to_string() }
109 }
110
111 pub fn with_table(mut self, table: impl Into<String>) -> DatabaseProgress {
112 self.table = table.into();
113 self
114 }
115}
116
117impl crate::progress::ProgressStore for DatabaseProgress {
118 fn set<'a>(&'a self, key: &'a str, progress: crate::progress::Progress) -> crate::progress::ProgressFuture<'a, ()> {
119 Box::pin(async move {
120 let values: Vec<(&str, Value)> = vec![
121 ("percent", Value::from(progress.percent as i64)),
122 ("stage", Value::from(progress.stage.as_str())),
123 ("updated_at", Value::from(progress.updated_at as i64)),
124 ("finished", Value::from(progress.finished)),
125 ("failed", progress.failed.as_deref().map_or(Value::Null, Value::from)),
126 ];
127
128 let updated = self.db.table(&self.table).filter("key", key).update(&self.db, &values).await?;
135 if updated > 0 {
136 return Ok(());
137 }
138 let mut insert = values.clone();
139 insert.push(("key", Value::from(key)));
140 if self.db.table(&self.table).insert_without_id(&self.db, &insert).await.is_err() {
141 self.db.table(&self.table).filter("key", key).update(&self.db, &values).await?;
142 }
143 Ok(())
144 })
145 }
146
147 fn get<'a>(&'a self, key: &'a str) -> crate::progress::ProgressFuture<'a, Option<crate::progress::Progress>> {
148 Box::pin(async move {
149 let row = self.db.table(&self.table).filter("key", key).first(&self.db).await?;
150 Ok(row.map(|row| crate::progress::Progress {
151 percent: row.get::<i64>("percent").unwrap_or(0).clamp(0, 100) as u8,
152 stage: row.get::<String>("stage").unwrap_or_default(),
153 updated_at: row.get::<i64>("updated_at").unwrap_or(0).max(0) as u64,
154 finished: row.get::<bool>("finished").unwrap_or(false),
155 failed: row.get::<String>("failed").ok().filter(|f| !f.is_empty()),
156 }))
157 })
158 }
159
160 fn purge<'a>(&'a self, before: u64) -> crate::progress::ProgressFuture<'a, usize> {
161 Box::pin(async move {
162 let removed = self
163 .db
164 .table(&self.table)
165 .filter_op("updated_at", "<", before as i64)
166 .delete(&self.db)
167 .await?;
168 Ok(removed as usize)
169 })
170 }
171}
172
173#[derive(Clone)]
178pub struct DatabaseQueue {
179 db: Database,
180 jobs: String,
181 failed: String,
182 jobs_sql: String,
184 failed_sql: String,
185}
186
187impl DatabaseQueue {
188 pub fn new(db: Database) -> Self {
190 DatabaseQueue::with_tables(db, JOBS_TABLE, FAILED_JOBS_TABLE)
191 .expect("the built-in table names are valid identifiers")
192 }
193
194 pub fn with_tables(db: Database, jobs: &str, failed: &str) -> Result<Self> {
197 Ok(DatabaseQueue {
198 jobs_sql: quote_identifier(jobs)?,
199 failed_sql: quote_identifier(failed)?,
200 jobs: jobs.to_string(),
201 failed: failed.to_string(),
202 db,
203 })
204 }
205
206 pub fn database(&self) -> &Database {
207 &self.db
208 }
209
210 pub async fn migrate(&self) -> Result<()> {
213 let schema = Schema::new(&self.db);
214 create_tables(&schema, &self.jobs, &self.failed).await?;
215 schema.create_if_missing(JOB_PROGRESS_TABLE, define_job_progress_table).await
220 }
221
222 pub async fn drop_tables(&self) -> Result<()> {
224 let schema = Schema::new(&self.db);
225 schema.drop(&self.failed).await?;
226 schema.drop(&self.jobs).await
227 }
228
229 async fn reserve(&self, queue: &str) -> Result<Option<ReservedJob>> {
250 let now = unix_now();
251 let mut tx = self.db.begin().await?;
252
253 let row = tx
254 .select_one(
255 &format!(
256 "select id, name, payload, attempts, max_tries, retry_after \
257 from {} \
258 where queue = $1 and reserved_at is null and available_at <= $2 \
259 order by id \
260 for update skip locked \
261 limit 1",
262 self.jobs_sql
263 ),
264 &[Value::from(queue), Value::from(now)],
265 )
266 .await?;
267
268 let Some(row) = row else {
269 tx.rollback().await?;
272 return Ok(None);
273 };
274
275 let id = row.get::<i64>("id")?;
276 let attempts = row.get::<i64>("attempts")? as u32 + 1;
277 let retry_after = Duration::from_secs(row.get::<i64>("retry_after")?.max(0) as u64);
278
279 tx.execute(
280 &format!(
281 "update {} set attempts = $1, reserved_at = $2 where id = $3",
282 self.jobs_sql
283 ),
284 &[Value::from(attempts), Value::from(now), Value::from(id)],
285 )
286 .await?;
287
288 tx.commit().await?;
289
290 let stored: Json = row.get::<Json>("payload")?;
293 let job = QueuedJob {
294 name: row.get::<String>("name")?,
295 payload: Json::Null,
296 queue: queue.to_string(),
297 max_tries: row.get::<i64>("max_tries")?.max(1) as u32,
298 retry_after,
299 delay: Duration::ZERO,
300 chain: None,
301 then: Vec::new(),
302 }
303 .with_stored_payload(stored);
304
305 Ok(Some(ReservedJob {
306 id: id.to_string(),
307 job,
308 attempts,
309 }))
310 }
311
312 pub async fn reclaim_expired(&self, queue: &str) -> Result<u64> {
321 let now = unix_now();
322
323 self.db
324 .execute(
325 &format!(
326 "update {} set reserved_at = null, available_at = $1 \
327 where queue = $2 and reserved_at is not null and reserved_at + retry_after < $3",
328 self.jobs_sql
329 ),
330 &[Value::from(now), Value::from(queue), Value::from(now)],
331 )
332 .await
333 }
334
335 fn row_to_failed(row: &rustlavel_db::Row) -> Result<FailedJob> {
336 Ok(FailedJob {
337 id: row.get::<i64>("id")?.to_string(),
338 name: row.get::<String>("name")?,
339 queue: row.get::<String>("queue")?,
340 payload: row.get::<Json>("payload")?,
341 attempts: row.get::<i64>("attempts")?.max(0) as u32,
342 error: row.get::<String>("error")?,
343 failed_at: row.get::<i64>("failed_at")?,
344 })
345 }
346
347 fn job_id(job: &ReservedJob) -> Result<i64> {
348 job.id.parse::<i64>().map_err(|_| {
349 Error::msg(format!(
350 "`{}` is not an id from the database queue. Was this job reserved by a \
351 different driver?",
352 job.id
353 ))
354 })
355 }
356}
357
358impl Queue for DatabaseQueue {
359 fn driver(&self) -> &'static str {
360 "database"
361 }
362
363 fn push(&self, job: QueuedJob) -> BoxFuture<'_, Result<String>> {
364 Box::pin(async move {
365 let now = unix_now();
366 let id = self
367 .db
368 .table(&self.jobs)
369 .insert(
370 &self.db,
371 &[
372 ("queue", Value::from(job.queue.as_str())),
373 ("name", Value::from(job.name.as_str())),
374 ("payload", Value::from(job.stored_payload())),
375 ("attempts", Value::from(0)),
376 ("max_tries", Value::from(job.max_tries)),
377 ("retry_after", Value::from(job.retry_after.as_secs() as i64)),
378 ("available_at", Value::from(now + job.delay.as_secs() as i64)),
379 ("created_at", Value::from(now)),
380 ],
381 )
382 .await?
383 .to_string();
384
385 record_pushed(self.driver(), &job, &id);
386 Ok(id)
387 })
388 }
389
390 fn pop<'a>(&'a self, queue: &'a str) -> BoxFuture<'a, Result<Option<ReservedJob>>> {
391 Box::pin(async move {
392 if let Some(job) = self.reserve(queue).await? {
393 return Ok(Some(job));
394 }
395
396 if self.reclaim_expired(queue).await? > 0 {
400 return self.reserve(queue).await;
401 }
402
403 Ok(None)
404 })
405 }
406
407 fn size<'a>(&'a self, queue: &'a str) -> BoxFuture<'a, Result<u64>> {
408 Box::pin(async move {
409 let count = self.db.table(&self.jobs).filter("queue", queue).count(&self.db).await?;
410 Ok(count.max(0) as u64)
411 })
412 }
413
414 fn delete<'a>(&'a self, job: &'a ReservedJob) -> BoxFuture<'a, Result<()>> {
415 Box::pin(async move {
416 self.db
417 .table(&self.jobs)
418 .filter("id", DatabaseQueue::job_id(job)?)
419 .delete(&self.db)
420 .await?;
421 Ok(())
422 })
423 }
424
425 fn release<'a>(&'a self, job: &'a ReservedJob, delay: Duration) -> BoxFuture<'a, Result<()>> {
426 Box::pin(async move {
427 self.db
428 .table(&self.jobs)
429 .filter("id", DatabaseQueue::job_id(job)?)
430 .update(
431 &self.db,
432 &[
433 ("reserved_at", Value::Null),
434 ("available_at", Value::from(unix_now() + delay.as_secs() as i64)),
435 ],
436 )
437 .await?;
438 Ok(())
439 })
440 }
441
442 fn fail<'a>(&'a self, job: &'a ReservedJob, error: &'a str) -> BoxFuture<'a, Result<()>> {
443 Box::pin(async move {
444 let id = DatabaseQueue::job_id(job)?;
445
446 let mut tx = self.db.begin().await?;
448
449 tx.execute(
450 &format!(
451 "insert into {} (queue, name, payload, attempts, error, failed_at) \
452 values ($1, $2, $3, $4, $5, $6)",
453 self.failed_sql
454 ),
455 &[
456 Value::from(job.job.queue.as_str()),
457 Value::from(job.job.name.as_str()),
458 Value::from(job.job.stored_payload()),
462 Value::from(job.attempts),
463 Value::from(error),
464 Value::from(unix_now()),
465 ],
466 )
467 .await?;
468
469 tx.execute(&format!("delete from {} where id = $1", self.jobs_sql), &[Value::from(id)])
470 .await?;
471
472 tx.commit().await
473 })
474 }
475
476 fn failed_jobs(&self) -> BoxFuture<'_, Result<Vec<FailedJob>>> {
477 Box::pin(async move {
478 let rows = self
479 .db
480 .select(&format!("select * from {} order by id", self.failed_sql), &[])
481 .await?;
482 rows.iter().map(DatabaseQueue::row_to_failed).collect()
483 })
484 }
485
486 fn clear<'a>(&'a self, queue: &'a str) -> BoxFuture<'a, Result<u64>> {
487 Box::pin(async move {
488 self.db.table(&self.jobs).filter("queue", queue).delete(&self.db).await
489 })
490 }
491}
492
493#[cfg(test)]
494mod tests {
495 use super::*;
496 use rustlavel_db::Migration;
497
498 #[test]
499 fn table_names_are_validated_once_at_construction() {
500 let db = Database::lazy(rustlavel_db::DatabaseConfig::default())
501 .expect("the default configuration names a driver that exists");
502
503 assert!(DatabaseQueue::with_tables(db.clone(), "app_jobs", "app_failed").is_ok());
504
505 let error = match DatabaseQueue::with_tables(db, "jobs; drop table users", "failed") {
506 Err(error) => error.to_string(),
507 Ok(_) => panic!("an injected table name should have been rejected"),
508 };
509 assert!(error.contains("not a valid SQL identifier"), "{error}");
510 }
511
512 #[test]
513 fn the_jobs_table_has_the_columns_the_driver_reads() {
514 let jobs = rustlavel_db::schema::create_statements(
517 &rustlavel_db::dialect::Postgres,
518 JOBS_TABLE,
519 define_jobs_table,
520 )
521 .unwrap();
522
523 for column in [
524 r#""id" bigserial primary key"#,
525 r#""queue" varchar(255) not null"#,
526 r#""name" varchar(255) not null"#,
527 r#""payload" jsonb not null"#,
528 r#""attempts" integer not null default 0"#,
529 r#""max_tries" integer not null default 3"#,
530 r#""retry_after" integer not null default 60"#,
531 r#""available_at" bigint not null"#,
532 r#""created_at" bigint not null"#,
533 ] {
534 assert!(jobs[0].contains(column), "expected {column} in:\n{}", jobs[0]);
535 }
536
537 assert!(jobs[0].contains(r#""reserved_at" bigint,"#), "{}", jobs[0]);
540 assert!(!jobs[0].contains(r#""reserved_at" bigint not null"#), "{}", jobs[0]);
541
542 assert!(
543 jobs[1].contains(r#"on "jobs" ("queue", "reserved_at", "available_at")"#),
544 "the reservation query needs a covering index:\n{}",
545 jobs[1]
546 );
547 }
548
549 #[test]
550 fn the_failed_jobs_table_keeps_everything_needed_to_diagnose_a_job() {
551 let failed =
552 rustlavel_db::schema::create_statements(
553 &rustlavel_db::dialect::Postgres,
554 FAILED_JOBS_TABLE,
555 define_failed_jobs_table,
556 )
557 .unwrap();
558
559 for column in [
560 r#""queue" varchar(255) not null"#,
561 r#""name" varchar(255) not null"#,
562 r#""payload" jsonb not null"#,
563 r#""attempts" integer not null"#,
564 r#""error" text not null"#,
565 r#""failed_at" bigint not null"#,
566 ] {
567 assert!(failed[0].contains(column), "expected {column} in:\n{}", failed[0]);
568 }
569 }
570
571 #[test]
572 fn the_migration_is_registered_under_a_sortable_name() {
573 assert_eq!(CreateQueueTables.name(), "2026_08_29_000001_create_queue_tables");
574 }
575
576 #[test]
577 fn an_id_from_another_driver_is_rejected_rather_than_silently_ignored() {
578 let job = ReservedJob {
579 id: "memory-7".to_string(),
580 job: QueuedJob::new("x", Json::Null),
581 attempts: 1,
582 };
583
584 let error = DatabaseQueue::job_id(&job).unwrap_err().to_string();
585 assert!(error.contains("not an id from the database queue"), "{error}");
586 }
587}