Skip to main content

rainier_queue/
database.rs

1//! The [`DatabaseQueue`] driver — jobs that survive a restart.
2//!
3//! Rows in two tables, defined as ordinary Rainier ORM entities, so the queue
4//! runs on whatever backend the application already uses (SQLite, D1, MySQL,
5//! Postgres) with no extra infrastructure.
6//!
7//! ## Reserving without losing or duplicating jobs
8//!
9//! Two workers polling the same table will see the same row. A plain
10//! `SELECT … LIMIT 1` followed by `UPDATE` would hand it to both.
11//!
12//! The fix here is an **optimistic claim**: select a candidate, then update it
13//! with `WHERE id = ? AND reserved_at IS NULL` and check the affected-row
14//! count. Exactly one worker's update touches a row; the losers see zero and
15//! try the next candidate. That needs no `SELECT … FOR UPDATE`, no advisory
16//! lock, and no dialect-specific syntax — which matters because D1 has none of
17//! them.
18//!
19//! A worker that dies mid-job leaves a row reserved forever, so a reservation
20//! also carries a deadline: [`reclaim_expired`](DatabaseQueue::reclaim_expired)
21//! releases anything held past it.
22
23use std::sync::Arc;
24use std::time::Duration;
25
26use chrono::{DateTime, Utc};
27use rainier_database::{Criteria, Database, EntityRepository, Model, Repository};
28use rainier_orm::Entity;
29use rainier_support::{BoxFuture, Error, Result};
30
31use crate::job::QueuedJob;
32use crate::queue::Queue;
33
34/// A queued job, as a row.
35#[derive(Entity, Clone, Debug, PartialEq)]
36#[orm(table = "rainier_jobs")]
37#[orm(index = "queue, reserved_at")]
38pub struct JobRow {
39    /// The queued job's id.
40    #[orm(pk)]
41    pub id: String,
42    /// Which queue it is on.
43    #[orm(index)]
44    pub queue: String,
45    /// The job's registered name.
46    pub name: String,
47    /// Its serialised body, as JSON text.
48    pub payload: String,
49    /// How many times it has been attempted.
50    pub attempts: i64,
51    /// How many attempts it gets.
52    pub max_attempts: i64,
53    /// The earliest it may run.
54    pub available_at: DateTime<Utc>,
55    /// When a worker claimed it, or `NULL` if it is free.
56    pub reserved_at: Option<DateTime<Utc>>,
57    /// When the current reservation lapses.
58    pub reserved_until: Option<DateTime<Utc>>,
59    /// When it was enqueued.
60    pub created_at: DateTime<Utc>,
61    /// The uniqueness lock this job holds, if it declared a `unique_id`.
62    ///
63    /// A column rather than something folded into the payload, because the
64    /// worker needs it to release the lock and reading it should not mean
65    /// deserialising the job.
66    pub unique_key: Option<String>,
67}
68
69impl Model for JobRow {}
70
71/// A job that exhausted its attempts, as a row.
72#[derive(Entity, Clone, Debug, PartialEq)]
73#[orm(table = "rainier_failed_jobs")]
74pub struct FailedJobRow {
75    /// The failed job's id — the same id it had while queued.
76    #[orm(pk)]
77    pub id: String,
78    /// Which queue it was on.
79    #[orm(index)]
80    pub queue: String,
81    /// The job's registered name.
82    pub name: String,
83    /// Its serialised body.
84    pub payload: String,
85    /// The last error's message.
86    pub error: String,
87    /// When it gave up.
88    pub failed_at: DateTime<Utc>,
89}
90
91impl Model for FailedJobRow {}
92
93impl JobRow {
94    fn from_queued(job: &QueuedJob) -> Result<Self> {
95        Ok(Self {
96            id: job.id.clone(),
97            queue: job.queue.clone(),
98            name: job.name.clone(),
99            payload: serde_json::to_string(&job.payload)?,
100            attempts: job.attempts as i64,
101            max_attempts: job.max_attempts as i64,
102            available_at: job.available_at,
103            reserved_at: None,
104            reserved_until: None,
105            created_at: job.created_at,
106            unique_key: job.unique_key.clone(),
107        })
108    }
109
110    fn into_queued(self) -> Result<QueuedJob> {
111        Ok(QueuedJob {
112            id: self.id,
113            name: self.name,
114            payload: serde_json::from_str(&self.payload)?,
115            queue: self.queue,
116            attempts: self.attempts.max(0) as u32,
117            max_attempts: self.max_attempts.max(0) as u32,
118            available_at: self.available_at,
119            created_at: self.created_at,
120            unique_key: self.unique_key,
121        })
122    }
123}
124
125/// A queue backed by database rows.
126pub struct DatabaseQueue {
127    db: Database,
128    jobs: Arc<dyn Repository<JobRow>>,
129    failed: Arc<dyn Repository<FailedJobRow>>,
130    /// How long a reservation lasts before another worker may reclaim it.
131    reservation: Duration,
132    /// How many candidates to try before giving up a reserve attempt.
133    ///
134    /// Bounded so a burst of contention cannot spin: with N workers racing,
135    /// each loses at most this many claims before returning idle and being
136    /// retried on the next poll.
137    max_claim_attempts: usize,
138}
139
140impl DatabaseQueue {
141    /// A database queue over `db`.
142    pub fn new(db: Database) -> Self {
143        Self {
144            jobs: Arc::new(EntityRepository::<JobRow>::new(db.clone())),
145            failed: Arc::new(EntityRepository::<FailedJobRow>::new(db.clone())),
146            db,
147            reservation: Duration::from_secs(90),
148            max_claim_attempts: 5,
149        }
150    }
151
152    /// How long a worker's claim on a job lasts.
153    ///
154    /// Must exceed the worker's job timeout, or a job could be reclaimed and
155    /// run twice while the first attempt is still going.
156    pub fn with_reservation(mut self, reservation: Duration) -> Self {
157        self.reservation = reservation;
158        self
159    }
160
161    /// The migrations this driver needs.
162    pub fn migrations() -> rainier_database::Migrator {
163        rainier_database::Migrator::new()
164            .create_table::<JobRow>("rainier_queue_0001_jobs")
165            .create_table::<FailedJobRow>("rainier_queue_0002_failed_jobs")
166    }
167
168    /// Release every job whose reservation has lapsed, so a job held by a
169    /// worker that died becomes available again. Returns how many.
170    pub async fn reclaim_expired(&self) -> Result<u64> {
171        let stale = self
172            .jobs
173            .matching(
174                Criteria::new()
175                    .where_not_null("reserved_at")
176                    .where_lt("reserved_until", Utc::now())
177                    .limit(100),
178            )
179            .await?;
180
181        let mut reclaimed = 0;
182        for mut row in stale {
183            row.reserved_at = None;
184            row.reserved_until = None;
185            reclaimed += self.jobs.update(&row).await?;
186        }
187        Ok(reclaimed)
188    }
189
190    /// Every failed job, newest first.
191    pub async fn failed_jobs(&self, limit: u64) -> Result<Vec<FailedJobRow>> {
192        self.jobs_repository_failed(limit).await
193    }
194
195    async fn jobs_repository_failed(&self, limit: u64) -> Result<Vec<FailedJobRow>> {
196        self.failed.matching(Criteria::new().order_by_desc("failed_at").limit(limit)).await
197    }
198
199    /// Put a failed job back on its queue for another run.
200    pub async fn retry_failed(&self, id: &str) -> Result<bool> {
201        let Some(row) = self.failed.find(id.into()).await? else {
202            return Ok(false);
203        };
204
205        self.jobs
206            .create(JobRow {
207                id: row.id.clone(),
208                queue: row.queue.clone(),
209                name: row.name.clone(),
210                payload: row.payload.clone(),
211                attempts: 0,
212                max_attempts: 1,
213                available_at: Utc::now(),
214                reserved_at: None,
215                reserved_until: None,
216                created_at: Utc::now(),
217                // Not re-claimed. The original claim was released when the job
218                // failed, and taking a new one here would mean a retry could be
219                // refused by a *fresh* dispatch that happened in between —
220                // which is the opposite of what retrying a failure should do.
221                unique_key: None,
222            })
223            .await?;
224
225        self.failed.delete(id.into()).await?;
226        Ok(true)
227    }
228
229    /// Try to claim `row` for this worker. `Ok(true)` if we got it.
230    async fn claim(&self, row: &JobRow) -> Result<bool> {
231        let now = Utc::now();
232        let mut claimed = row.clone();
233        claimed.attempts += 1;
234        claimed.reserved_at = Some(now);
235        claimed.reserved_until = Some(
236            now + chrono::Duration::from_std(self.reservation)
237                .unwrap_or_else(|_| chrono::Duration::seconds(90)),
238        );
239
240        // The race is resolved here: the update only matches while the row is
241        // still unreserved, so exactly one worker's update affects a row.
242        let affected = self
243            .db
244            .execute(rainier_database::statement::update_matching::<JobRow>(
245                self.db.dialect(),
246                &Criteria::new().where_eq("id", row.id.clone()).where_null("reserved_at"),
247                vec![
248                    ("attempts".into(), claimed.attempts.into()),
249                    ("reserved_at".into(), now.into()),
250                    ("reserved_until".into(), claimed.reserved_until.expect("just set").into()),
251                ],
252            ))
253            .await?
254            .rows_affected;
255
256        Ok(affected == 1)
257    }
258}
259
260impl Queue for DatabaseQueue {
261    fn name(&self) -> &str {
262        "database"
263    }
264
265    fn push<'a>(&'a self, job: QueuedJob) -> BoxFuture<'a, Result<String>> {
266        Box::pin(async move {
267            let row = JobRow::from_queued(&job)?;
268            let id = row.id.clone();
269            self.jobs.create(row).await?;
270            Ok(id)
271        })
272    }
273
274    fn reserve<'a>(&'a self, queue: &'a str) -> BoxFuture<'a, Result<Option<QueuedJob>>> {
275        Box::pin(async move {
276            let candidates = self
277                .jobs
278                .matching(
279                    Criteria::new()
280                        .where_eq("queue", queue)
281                        .where_null("reserved_at")
282                        .where_lte("available_at", Utc::now())
283                        // Oldest first, so the queue is roughly FIFO.
284                        .order_by("available_at")
285                        .limit(self.max_claim_attempts as u64),
286                )
287                .await?;
288
289            for row in candidates {
290                if self.claim(&row).await? {
291                    let mut queued = row.into_queued()?;
292                    // `claim` incremented the stored count; reflect it here so
293                    // the worker's attempt number matches the row's.
294                    queued.attempts += 1;
295                    return Ok(Some(queued));
296                }
297            }
298            Ok(None)
299        })
300    }
301
302    fn acknowledge<'a>(&'a self, job: &'a QueuedJob) -> BoxFuture<'a, Result<()>> {
303        Box::pin(async move {
304            self.jobs.delete(job.id.clone().into()).await?;
305            Ok(())
306        })
307    }
308
309    fn release<'a>(&'a self, job: &'a QueuedJob, delay: Duration) -> BoxFuture<'a, Result<()>> {
310        Box::pin(async move {
311            let available_at = Utc::now()
312                + chrono::Duration::from_std(delay).unwrap_or_else(|_| chrono::Duration::zero());
313
314            self.db
315                .execute(rainier_database::statement::update_matching::<JobRow>(
316                    self.db.dialect(),
317                    &Criteria::new().where_eq("id", job.id.clone()),
318                    vec![
319                        (
320                            "reserved_at".into(),
321                            rainier_orm::sea_query::Value::ChronoDateTimeUtc(None),
322                        ),
323                        (
324                            "reserved_until".into(),
325                            rainier_orm::sea_query::Value::ChronoDateTimeUtc(None),
326                        ),
327                        ("available_at".into(), available_at.into()),
328                    ],
329                ))
330                .await?;
331            Ok(())
332        })
333    }
334
335    fn fail<'a>(&'a self, job: &'a QueuedJob, error: &'a str) -> BoxFuture<'a, Result<()>> {
336        Box::pin(async move {
337            self.failed
338                .create(FailedJobRow {
339                    id: job.id.clone(),
340                    queue: job.queue.clone(),
341                    name: job.name.clone(),
342                    payload: serde_json::to_string(&job.payload)?,
343                    error: error.to_string(),
344                    failed_at: Utc::now(),
345                })
346                .await
347                .map_err(|e| {
348                    Error::internal(format!("could not record the failed job `{}`: {e}", job.id))
349                })?;
350
351            self.jobs.delete(job.id.clone().into()).await?;
352            Ok(())
353        })
354    }
355
356    fn size<'a>(&'a self, queue: &'a str) -> BoxFuture<'a, Result<u64>> {
357        Box::pin(async move {
358            self.jobs
359                .count_matching(Criteria::new().where_eq("queue", queue).where_null("reserved_at"))
360                .await
361        })
362    }
363
364    fn clear<'a>(&'a self, queue: &'a str) -> BoxFuture<'a, Result<u64>> {
365        Box::pin(async move {
366            self.jobs.delete_matching(Criteria::new().where_eq("queue", queue)).await
367        })
368    }
369}
370
371impl std::fmt::Debug for DatabaseQueue {
372    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
373        f.debug_struct("DatabaseQueue").field("reservation", &self.reservation).finish()
374    }
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380    use crate::job::{Job, JobContext};
381    use rainier_database::testing::{fake_database, MemoryConnection};
382    use rainier_database::OwnedRow;
383    use rainier_orm::Dialect;
384    use serde::{Deserialize, Serialize};
385
386    #[derive(Serialize, Deserialize)]
387    struct Ping;
388
389    #[async_trait::async_trait]
390    impl Job for Ping {
391        const NAME: &'static str = "test.ping";
392        async fn handle(&self, _: &JobContext) -> Result<()> {
393            Ok(())
394        }
395    }
396
397    fn job_row(id: &str) -> OwnedRow {
398        OwnedRow::new()
399            .with("id", id)
400            .with("queue", "default")
401            .with("name", "test.ping")
402            .with("payload", "null")
403            .with("attempts", 0_i64)
404            .with("max_attempts", 3_i64)
405            .with("available_at", "2020-01-01T00:00:00Z")
406            .with("reserved_at", None::<String>)
407            .with("reserved_until", None::<String>)
408            .with("created_at", "2020-01-01T00:00:00Z")
409    }
410
411    #[test]
412    fn the_row_entities_describe_their_tables() {
413        assert_eq!(JobRow::table(), "rainier_jobs");
414        assert_eq!(FailedJobRow::table(), "rainier_failed_jobs");
415        assert_eq!(JobRow::primary_key(), "id");
416    }
417
418    #[test]
419    fn the_driver_ships_its_own_migrations() {
420        let migrator = DatabaseQueue::migrations();
421        assert_eq!(
422            migrator.names(),
423            vec!["rainier_queue_0001_jobs", "rainier_queue_0002_failed_jobs"]
424        );
425    }
426
427    #[test]
428    fn a_queued_job_round_trips_through_a_row() {
429        let queued = QueuedJob::from_job(&Ping).unwrap().on_queue("mail");
430        let row = JobRow::from_queued(&queued).unwrap();
431
432        assert_eq!(row.queue, "mail");
433        assert_eq!(row.name, "test.ping");
434        assert!(row.reserved_at.is_none());
435
436        let back = row.into_queued().unwrap();
437        assert_eq!(back.id, queued.id);
438        assert_eq!(back.name, queued.name);
439        assert_eq!(back.max_attempts, queued.max_attempts);
440    }
441
442    #[tokio::test]
443    async fn pushing_writes_a_row() {
444        let (db, connection) = fake_database(MemoryConnection::new(Dialect::Sqlite));
445        let queue = DatabaseQueue::new(db);
446
447        queue.push(QueuedJob::from_job(&Ping).unwrap()).await.unwrap();
448
449        let sql = connection.last_statement().unwrap();
450        assert!(sql.starts_with("INSERT INTO"), "{sql}");
451        assert!(sql.contains("rainier_jobs"), "{sql}");
452    }
453
454    #[tokio::test]
455    async fn reserving_selects_only_free_available_jobs() {
456        let (db, connection) = fake_database(MemoryConnection::new(Dialect::Sqlite));
457        let queue = DatabaseQueue::new(db);
458
459        queue.reserve("default").await.unwrap();
460
461        let sql = connection.statements()[0].clone();
462        assert!(sql.contains("reserved_at"), "{sql}");
463        assert!(sql.contains("IS NULL"), "{sql}");
464        assert!(sql.contains("available_at"), "{sql}");
465        assert!(sql.contains("ORDER BY"), "{sql}");
466    }
467
468    #[tokio::test]
469    async fn a_claim_is_conditional_on_the_job_still_being_free() {
470        // The race guard: the UPDATE must carry `reserved_at IS NULL`, or two
471        // workers could both believe they claimed the same job.
472        let (db, connection) = fake_database(
473            MemoryConnection::new(Dialect::Sqlite).returning([job_row("job-1")]).with_outcome(1, 0),
474        );
475        let queue = DatabaseQueue::new(db);
476
477        let reserved = queue.reserve("default").await.unwrap();
478        assert!(reserved.is_some());
479
480        let update = connection
481            .statements()
482            .into_iter()
483            .find(|s| s.starts_with("UPDATE"))
484            .expect("the claim should be an UPDATE");
485        assert!(update.contains("IS NULL"), "{update}");
486        assert!(update.contains("reserved_at"), "{update}");
487    }
488
489    #[tokio::test]
490    async fn losing_the_claim_race_yields_no_job() {
491        // `rows_affected == 0` means another worker got there first.
492        let (db, _) = fake_database(
493            MemoryConnection::new(Dialect::Sqlite).returning([job_row("job-1")]).with_outcome(0, 0),
494        );
495        let queue = DatabaseQueue::new(db);
496
497        assert!(queue.reserve("default").await.unwrap().is_none());
498    }
499
500    #[tokio::test]
501    async fn a_reserved_job_reports_the_attempt_the_row_now_holds() {
502        let (db, _) = fake_database(
503            MemoryConnection::new(Dialect::Sqlite).returning([job_row("job-1")]).with_outcome(1, 0),
504        );
505        let queue = DatabaseQueue::new(db);
506
507        let reserved = queue.reserve("default").await.unwrap().unwrap();
508        assert_eq!(reserved.attempts, 1, "the claim incremented it");
509    }
510
511    #[tokio::test]
512    async fn acknowledging_deletes_the_row() {
513        let (db, connection) = fake_database(MemoryConnection::new(Dialect::Sqlite));
514        let queue = DatabaseQueue::new(db);
515
516        let job = QueuedJob::from_job(&Ping).unwrap();
517        queue.acknowledge(&job).await.unwrap();
518
519        assert!(connection.last_statement().unwrap().starts_with("DELETE FROM"));
520    }
521
522    #[tokio::test]
523    async fn releasing_clears_the_reservation_and_pushes_availability_out() {
524        let (db, connection) = fake_database(MemoryConnection::new(Dialect::Sqlite));
525        let queue = DatabaseQueue::new(db);
526
527        let job = QueuedJob::from_job(&Ping).unwrap();
528        queue.release(&job, Duration::from_secs(30)).await.unwrap();
529
530        let sql = connection.last_statement().unwrap();
531        assert!(sql.starts_with("UPDATE"), "{sql}");
532        assert!(sql.contains("reserved_at"), "{sql}");
533        assert!(sql.contains("available_at"), "{sql}");
534    }
535
536    #[tokio::test]
537    async fn failing_records_the_row_before_removing_the_job() {
538        let (db, connection) = fake_database(MemoryConnection::new(Dialect::Sqlite));
539        let queue = DatabaseQueue::new(db);
540
541        let job = QueuedJob::from_job(&Ping).unwrap();
542        queue.fail(&job, "exploded").await.unwrap();
543
544        let statements = connection.statements();
545        let insert = statements.iter().position(|s| s.contains("rainier_failed_jobs"));
546        let delete = statements.iter().position(|s| s.starts_with("DELETE FROM"));
547
548        assert!(insert.is_some(), "{statements:?}");
549        assert!(delete.is_some(), "{statements:?}");
550        assert!(insert < delete, "record the failure before dropping the job");
551    }
552
553    #[tokio::test]
554    async fn size_counts_only_unreserved_jobs() {
555        let (db, connection) = fake_database(
556            MemoryConnection::new(Dialect::Sqlite).returning([OwnedRow::new().with("cnt", 7_i64)]),
557        );
558        let queue = DatabaseQueue::new(db);
559
560        assert_eq!(queue.size("default").await.unwrap(), 7);
561        let sql = connection.last_statement().unwrap();
562        assert!(sql.contains("COUNT"), "{sql}");
563        assert!(sql.contains("IS NULL"), "{sql}");
564    }
565
566    #[tokio::test]
567    async fn reclaiming_releases_jobs_held_past_their_deadline() {
568        let (db, connection) = fake_database(MemoryConnection::new(Dialect::Sqlite));
569        let queue = DatabaseQueue::new(db);
570
571        queue.reclaim_expired().await.unwrap();
572
573        let sql = connection.last_statement().unwrap();
574        assert!(sql.contains("reserved_until"), "{sql}");
575        assert!(sql.contains("IS NOT NULL"), "{sql}");
576    }
577}