1use 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#[derive(Entity, Clone, Debug, PartialEq)]
36#[orm(table = "rainier_jobs")]
37#[orm(index = "queue, reserved_at")]
38pub struct JobRow {
39 #[orm(pk)]
41 pub id: String,
42 #[orm(index)]
44 pub queue: String,
45 pub name: String,
47 pub payload: String,
49 pub attempts: i64,
51 pub max_attempts: i64,
53 pub available_at: DateTime<Utc>,
55 pub reserved_at: Option<DateTime<Utc>>,
57 pub reserved_until: Option<DateTime<Utc>>,
59 pub created_at: DateTime<Utc>,
61 pub unique_key: Option<String>,
67}
68
69impl Model for JobRow {}
70
71#[derive(Entity, Clone, Debug, PartialEq)]
73#[orm(table = "rainier_failed_jobs")]
74pub struct FailedJobRow {
75 #[orm(pk)]
77 pub id: String,
78 #[orm(index)]
80 pub queue: String,
81 pub name: String,
83 pub payload: String,
85 pub error: String,
87 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
125pub struct DatabaseQueue {
127 db: Database,
128 jobs: Arc<dyn Repository<JobRow>>,
129 failed: Arc<dyn Repository<FailedJobRow>>,
130 reservation: Duration,
132 max_claim_attempts: usize,
138}
139
140impl DatabaseQueue {
141 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 pub fn with_reservation(mut self, reservation: Duration) -> Self {
157 self.reservation = reservation;
158 self
159 }
160
161 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 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 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 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 unique_key: None,
222 })
223 .await?;
224
225 self.failed.delete(id.into()).await?;
226 Ok(true)
227 }
228
229 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 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 .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 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 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 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}