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
75#[derive(Clone)]
80pub struct DatabaseQueue {
81 db: Database,
82 jobs: String,
83 failed: String,
84 jobs_sql: String,
86 failed_sql: String,
87}
88
89impl DatabaseQueue {
90 pub fn new(db: Database) -> Self {
92 DatabaseQueue::with_tables(db, JOBS_TABLE, FAILED_JOBS_TABLE)
93 .expect("the built-in table names are valid identifiers")
94 }
95
96 pub fn with_tables(db: Database, jobs: &str, failed: &str) -> Result<Self> {
99 Ok(DatabaseQueue {
100 jobs_sql: quote_identifier(jobs)?,
101 failed_sql: quote_identifier(failed)?,
102 jobs: jobs.to_string(),
103 failed: failed.to_string(),
104 db,
105 })
106 }
107
108 pub fn database(&self) -> &Database {
109 &self.db
110 }
111
112 pub async fn migrate(&self) -> Result<()> {
115 let schema = Schema::new(&self.db);
116 create_tables(&schema, &self.jobs, &self.failed).await
117 }
118
119 pub async fn drop_tables(&self) -> Result<()> {
121 let schema = Schema::new(&self.db);
122 schema.drop(&self.failed).await?;
123 schema.drop(&self.jobs).await
124 }
125
126 async fn reserve(&self, queue: &str) -> Result<Option<ReservedJob>> {
147 let now = unix_now();
148 let mut tx = self.db.begin().await?;
149
150 let row = tx
151 .select_one(
152 &format!(
153 "select id, name, payload, attempts, max_tries, retry_after \
154 from {} \
155 where queue = $1 and reserved_at is null and available_at <= $2 \
156 order by id \
157 for update skip locked \
158 limit 1",
159 self.jobs_sql
160 ),
161 &[Value::from(queue), Value::from(now)],
162 )
163 .await?;
164
165 let Some(row) = row else {
166 tx.rollback().await?;
169 return Ok(None);
170 };
171
172 let id = row.get::<i64>("id")?;
173 let attempts = row.get::<i64>("attempts")? as u32 + 1;
174 let retry_after = Duration::from_secs(row.get::<i64>("retry_after")?.max(0) as u64);
175
176 tx.execute(
177 &format!(
178 "update {} set attempts = $1, reserved_at = $2 where id = $3",
179 self.jobs_sql
180 ),
181 &[Value::from(attempts), Value::from(now), Value::from(id)],
182 )
183 .await?;
184
185 tx.commit().await?;
186
187 Ok(Some(ReservedJob {
188 id: id.to_string(),
189 job: QueuedJob {
190 name: row.get::<String>("name")?,
191 payload: row.get::<Json>("payload")?,
192 queue: queue.to_string(),
193 max_tries: row.get::<i64>("max_tries")?.max(1) as u32,
194 retry_after,
195 delay: Duration::ZERO,
196 },
197 attempts,
198 }))
199 }
200
201 pub async fn reclaim_expired(&self, queue: &str) -> Result<u64> {
210 let now = unix_now();
211
212 self.db
213 .execute(
214 &format!(
215 "update {} set reserved_at = null, available_at = $1 \
216 where queue = $2 and reserved_at is not null and reserved_at + retry_after < $3",
217 self.jobs_sql
218 ),
219 &[Value::from(now), Value::from(queue), Value::from(now)],
220 )
221 .await
222 }
223
224 fn row_to_failed(row: &rustlavel_db::Row) -> Result<FailedJob> {
225 Ok(FailedJob {
226 id: row.get::<i64>("id")?.to_string(),
227 name: row.get::<String>("name")?,
228 queue: row.get::<String>("queue")?,
229 payload: row.get::<Json>("payload")?,
230 attempts: row.get::<i64>("attempts")?.max(0) as u32,
231 error: row.get::<String>("error")?,
232 failed_at: row.get::<i64>("failed_at")?,
233 })
234 }
235
236 fn job_id(job: &ReservedJob) -> Result<i64> {
237 job.id.parse::<i64>().map_err(|_| {
238 Error::msg(format!(
239 "`{}` is not an id from the database queue. Was this job reserved by a \
240 different driver?",
241 job.id
242 ))
243 })
244 }
245}
246
247impl Queue for DatabaseQueue {
248 fn driver(&self) -> &'static str {
249 "database"
250 }
251
252 fn push(&self, job: QueuedJob) -> BoxFuture<'_, Result<String>> {
253 Box::pin(async move {
254 let now = unix_now();
255 let id = self
256 .db
257 .table(&self.jobs)
258 .insert(
259 &self.db,
260 &[
261 ("queue", Value::from(job.queue.as_str())),
262 ("name", Value::from(job.name.as_str())),
263 ("payload", Value::from(job.payload.clone())),
264 ("attempts", Value::from(0)),
265 ("max_tries", Value::from(job.max_tries)),
266 ("retry_after", Value::from(job.retry_after.as_secs() as i64)),
267 ("available_at", Value::from(now + job.delay.as_secs() as i64)),
268 ("created_at", Value::from(now)),
269 ],
270 )
271 .await?
272 .to_string();
273
274 record_pushed(self.driver(), &job, &id);
275 Ok(id)
276 })
277 }
278
279 fn pop<'a>(&'a self, queue: &'a str) -> BoxFuture<'a, Result<Option<ReservedJob>>> {
280 Box::pin(async move {
281 if let Some(job) = self.reserve(queue).await? {
282 return Ok(Some(job));
283 }
284
285 if self.reclaim_expired(queue).await? > 0 {
289 return self.reserve(queue).await;
290 }
291
292 Ok(None)
293 })
294 }
295
296 fn size<'a>(&'a self, queue: &'a str) -> BoxFuture<'a, Result<u64>> {
297 Box::pin(async move {
298 let count = self.db.table(&self.jobs).filter("queue", queue).count(&self.db).await?;
299 Ok(count.max(0) as u64)
300 })
301 }
302
303 fn delete<'a>(&'a self, job: &'a ReservedJob) -> BoxFuture<'a, Result<()>> {
304 Box::pin(async move {
305 self.db
306 .table(&self.jobs)
307 .filter("id", DatabaseQueue::job_id(job)?)
308 .delete(&self.db)
309 .await?;
310 Ok(())
311 })
312 }
313
314 fn release<'a>(&'a self, job: &'a ReservedJob, delay: Duration) -> BoxFuture<'a, Result<()>> {
315 Box::pin(async move {
316 self.db
317 .table(&self.jobs)
318 .filter("id", DatabaseQueue::job_id(job)?)
319 .update(
320 &self.db,
321 &[
322 ("reserved_at", Value::Null),
323 ("available_at", Value::from(unix_now() + delay.as_secs() as i64)),
324 ],
325 )
326 .await?;
327 Ok(())
328 })
329 }
330
331 fn fail<'a>(&'a self, job: &'a ReservedJob, error: &'a str) -> BoxFuture<'a, Result<()>> {
332 Box::pin(async move {
333 let id = DatabaseQueue::job_id(job)?;
334
335 let mut tx = self.db.begin().await?;
337
338 tx.execute(
339 &format!(
340 "insert into {} (queue, name, payload, attempts, error, failed_at) \
341 values ($1, $2, $3, $4, $5, $6)",
342 self.failed_sql
343 ),
344 &[
345 Value::from(job.job.queue.as_str()),
346 Value::from(job.job.name.as_str()),
347 Value::from(job.job.payload.clone()),
348 Value::from(job.attempts),
349 Value::from(error),
350 Value::from(unix_now()),
351 ],
352 )
353 .await?;
354
355 tx.execute(&format!("delete from {} where id = $1", self.jobs_sql), &[Value::from(id)])
356 .await?;
357
358 tx.commit().await
359 })
360 }
361
362 fn failed_jobs(&self) -> BoxFuture<'_, Result<Vec<FailedJob>>> {
363 Box::pin(async move {
364 let rows = self
365 .db
366 .select(&format!("select * from {} order by id", self.failed_sql), &[])
367 .await?;
368 rows.iter().map(DatabaseQueue::row_to_failed).collect()
369 })
370 }
371
372 fn clear<'a>(&'a self, queue: &'a str) -> BoxFuture<'a, Result<u64>> {
373 Box::pin(async move {
374 self.db.table(&self.jobs).filter("queue", queue).delete(&self.db).await
375 })
376 }
377}
378
379#[cfg(test)]
380mod tests {
381 use super::*;
382 use rustlavel_db::Migration;
383
384 #[test]
385 fn table_names_are_validated_once_at_construction() {
386 let db = Database::lazy(rustlavel_db::DatabaseConfig::default())
387 .expect("the default configuration names a driver that exists");
388
389 assert!(DatabaseQueue::with_tables(db.clone(), "app_jobs", "app_failed").is_ok());
390
391 let error = match DatabaseQueue::with_tables(db, "jobs; drop table users", "failed") {
392 Err(error) => error.to_string(),
393 Ok(_) => panic!("an injected table name should have been rejected"),
394 };
395 assert!(error.contains("not a valid SQL identifier"), "{error}");
396 }
397
398 #[test]
399 fn the_jobs_table_has_the_columns_the_driver_reads() {
400 let jobs = rustlavel_db::schema::create_statements(
403 &rustlavel_db::dialect::Postgres,
404 JOBS_TABLE,
405 define_jobs_table,
406 )
407 .unwrap();
408
409 for column in [
410 r#""id" bigserial primary key"#,
411 r#""queue" varchar(255) not null"#,
412 r#""name" varchar(255) not null"#,
413 r#""payload" jsonb not null"#,
414 r#""attempts" integer not null default 0"#,
415 r#""max_tries" integer not null default 3"#,
416 r#""retry_after" integer not null default 60"#,
417 r#""available_at" bigint not null"#,
418 r#""created_at" bigint not null"#,
419 ] {
420 assert!(jobs[0].contains(column), "expected {column} in:\n{}", jobs[0]);
421 }
422
423 assert!(jobs[0].contains(r#""reserved_at" bigint,"#), "{}", jobs[0]);
426 assert!(!jobs[0].contains(r#""reserved_at" bigint not null"#), "{}", jobs[0]);
427
428 assert!(
429 jobs[1].contains(r#"on "jobs" ("queue", "reserved_at", "available_at")"#),
430 "the reservation query needs a covering index:\n{}",
431 jobs[1]
432 );
433 }
434
435 #[test]
436 fn the_failed_jobs_table_keeps_everything_needed_to_diagnose_a_job() {
437 let failed =
438 rustlavel_db::schema::create_statements(
439 &rustlavel_db::dialect::Postgres,
440 FAILED_JOBS_TABLE,
441 define_failed_jobs_table,
442 )
443 .unwrap();
444
445 for column in [
446 r#""queue" varchar(255) not null"#,
447 r#""name" varchar(255) not null"#,
448 r#""payload" jsonb not null"#,
449 r#""attempts" integer not null"#,
450 r#""error" text not null"#,
451 r#""failed_at" bigint not null"#,
452 ] {
453 assert!(failed[0].contains(column), "expected {column} in:\n{}", failed[0]);
454 }
455 }
456
457 #[test]
458 fn the_migration_is_registered_under_a_sortable_name() {
459 assert_eq!(CreateQueueTables.name(), "2026_08_29_000001_create_queue_tables");
460 }
461
462 #[test]
463 fn an_id_from_another_driver_is_rejected_rather_than_silently_ignored() {
464 let job = ReservedJob {
465 id: "memory-7".to_string(),
466 job: QueuedJob::new("x", Json::Null),
467 attempts: 1,
468 };
469
470 let error = DatabaseQueue::job_id(&job).unwrap_err().to_string();
471 assert!(error.contains("not an id from the database queue"), "{error}");
472 }
473}