1use crate::sql::Row;
22use serde_json::Value;
23use tracing::{debug, info, warn};
24use uuid::Uuid;
25
26use crate::db::Database;
27use crate::nonce::now_secs;
28use crate::status::JobStatus;
29
30#[derive(Debug, Clone)]
38pub struct Job {
39 pub id: Uuid,
40 pub kind: String,
41 pub dedup_key: String,
42 pub payload: Value,
43 pub status: String,
44 pub run_at: i64,
45 pub attempts: i64,
46 pub max_attempts: i64,
47 pub deadline: Option<i64>,
48 pub lease_until: Option<i64>,
49 pub lease_owner: Option<String>,
50 pub last_error: Option<String>,
51 pub created_at: i64,
52 pub updated_at: i64,
53}
54
55const COLUMNS: &str = "id, kind, dedup_key, payload, status, run_at, attempts, max_attempts, \
58 deadline, lease_until, lease_owner, last_error, created_at, updated_at";
59
60#[derive(Debug)]
69pub struct NewJob<'a> {
70 pub id: Uuid,
71 pub kind: &'a str,
72 pub dedup_key: &'a str,
73 pub payload: &'a Value,
74 pub run_at: i64,
75 pub deadline: Option<i64>,
76 pub max_attempts: i64,
79}
80
81#[derive(Debug, Clone, Default)]
92pub struct JobQuery {
93 pub kind: Option<String>,
94 pub status: Option<crate::status::JobStatus>,
95 pub limit: i64,
98 pub offset: i64,
99}
100
101impl JobQuery {
102 fn push_predicates(&self, builder: &mut crate::sql::Builder) {
106 crate::query::push_equalities(
107 builder,
108 crate::query::WHERE,
109 &[
110 ("kind = ", self.kind.as_deref()),
111 ("status = ", self.status.map(JobStatus::as_str)),
112 ],
113 );
114 }
115}
116
117impl Job {
118 fn from_row(row: Row) -> Result<Self, sqlx::Error> {
119 let payload_json: String = row.try_get("payload")?;
120 let payload: Value = serde_json::from_str(&payload_json)
121 .map_err(|error| sqlx::Error::Decode(Box::new(error)))?;
122
123 Ok(Self {
124 id: row.try_get("id")?,
125 kind: row.try_get("kind")?,
126 dedup_key: row.try_get("dedup_key")?,
127 payload,
128 status: row.try_get("status")?,
129 run_at: row.try_get("run_at")?,
130 attempts: row.try_get("attempts")?,
131 max_attempts: row.try_get("max_attempts")?,
132 deadline: row.try_get("deadline")?,
133 lease_until: row.try_get("lease_until")?,
134 lease_owner: row.try_get("lease_owner")?,
135 last_error: row.try_get("last_error")?,
136 created_at: row.try_get("created_at")?,
137 updated_at: row.try_get("updated_at")?,
138 })
139 }
140
141 pub async fn enqueue(row: NewJob<'_>, database: &Database) -> Result<bool, sqlx::Error> {
150 Self::enqueue_on(row, database).await
151 }
152
153 pub async fn enqueue_on<'e>(
156 row: NewJob<'_>,
157 executor: impl Into<crate::sql::Exec<'e>>,
158 ) -> Result<bool, sqlx::Error> {
159 let now = now_secs();
160 let queued = crate::sql::query(
161 "INSERT INTO jobs \
162 (id, kind, dedup_key, payload, status, run_at, attempts, max_attempts, \
163 deadline, created_at, updated_at) \
164 VALUES (?, ?, ?, ?, 'ready', ?, 0, ?, ?, ?, ?) \
165 ON CONFLICT DO NOTHING;",
166 )
167 .bind(row.id)
168 .bind(row.kind)
169 .bind(row.dedup_key)
170 .bind(row.payload.to_string())
171 .bind(row.run_at)
172 .bind(row.max_attempts)
173 .bind(row.deadline)
174 .bind(now)
175 .bind(now)
176 .execute(executor)
177 .await?
178 .rows_affected()
179 == 1;
180
181 if queued {
182 debug!(
183 event = "db_job_enqueued",
184 outcome = "success",
185 job_id = %row.id,
186 job_kind = %row.kind,
187 dedup_key = %row.dedup_key,
188 run_at = row.run_at,
189 );
190 }
191 Ok(queued)
192 }
193
194 pub async fn claim_next(
210 runner_id: &str,
211 kinds: &[&str],
212 lease_until: i64,
213 now: i64,
214 database: &Database,
215 ) -> Result<Option<Self>, sqlx::Error> {
216 if kinds.is_empty() {
217 return Ok(None);
218 }
219
220 let placeholders = std::iter::repeat_n("?", kinds.len())
226 .collect::<Vec<_>>()
227 .join(", ");
228 let sql = format!(
229 "UPDATE jobs \
230 SET status = 'running', attempts = attempts + 1, lease_owner = ?, \
231 lease_until = ?, updated_at = ? \
232 WHERE id = (SELECT id FROM jobs \
233 WHERE status = 'ready' AND run_at <= ? AND kind IN ({placeholders}) \
234 ORDER BY run_at ASC, created_at ASC LIMIT 1) \
235 AND status = 'ready' \
236 RETURNING {COLUMNS};"
237 );
238
239 let mut query = crate::sql::query(sqlx::AssertSqlSafe(sql))
240 .bind(runner_id)
241 .bind(lease_until)
242 .bind(now)
243 .bind(now);
244 for kind in kinds {
245 query = query.bind(*kind);
246 }
247 let row = query.fetch_optional(database).await?;
248 let job = row.map(Self::from_row).transpose()?;
249
250 if let Some(job) = &job {
251 debug!(
252 event = "db_job_claimed",
253 outcome = "success",
254 job_id = %job.id,
255 job_kind = %job.kind,
256 attempts = job.attempts,
257 lease_until = lease_until,
258 );
259 }
260 Ok(job)
261 }
262
263 pub async fn complete(
265 id: Uuid,
266 runner_id: &str,
267 database: &Database,
268 ) -> Result<bool, sqlx::Error> {
269 Self::settle(id, runner_id, "done", None, None, false, database).await
270 }
271
272 pub async fn retry(
274 id: Uuid,
275 runner_id: &str,
276 run_at: i64,
277 error: &str,
278 database: &Database,
279 ) -> Result<bool, sqlx::Error> {
280 Self::settle(
281 id,
282 runner_id,
283 "ready",
284 Some(run_at),
285 Some(error),
286 false,
287 database,
288 )
289 .await
290 }
291
292 pub async fn reschedule(
300 id: Uuid,
301 runner_id: &str,
302 run_at: i64,
303 database: &Database,
304 ) -> Result<bool, sqlx::Error> {
305 Self::settle(id, runner_id, "ready", Some(run_at), None, true, database).await
306 }
307
308 pub async fn abandon(
310 id: Uuid,
311 runner_id: &str,
312 error: &str,
313 database: &Database,
314 ) -> Result<bool, sqlx::Error> {
315 Self::settle(id, runner_id, "failed", None, Some(error), false, database).await
316 }
317
318 async fn settle(
326 id: Uuid,
327 runner_id: &str,
328 status: &str,
329 run_at: Option<i64>,
330 error: Option<&str>,
331 reset_attempts: bool,
332 database: &Database,
333 ) -> Result<bool, sqlx::Error> {
334 let now = now_secs();
335 let sql = format!(
336 "UPDATE jobs \
337 SET status = ?, last_error = ?, lease_owner = NULL, lease_until = NULL, \
338 run_at = COALESCE(?, run_at), updated_at = ?{} \
339 WHERE id = ? AND status = 'running' AND lease_owner = ?;",
340 if reset_attempts { ", attempts = 0" } else { "" }
341 );
342 let settled = crate::sql::query(sqlx::AssertSqlSafe(sql))
343 .bind(status)
344 .bind(error)
345 .bind(run_at)
346 .bind(now)
347 .bind(id)
348 .bind(runner_id)
349 .execute(database)
350 .await?
351 .rows_affected()
352 == 1;
353 Ok(settled)
354 }
355
356 pub async fn reclaim_expired(now: i64, database: &Database) -> Result<u64, sqlx::Error> {
362 let reclaimed = crate::sql::query(
363 "UPDATE jobs SET status = 'ready', lease_owner = NULL, lease_until = NULL, \
364 updated_at = ? WHERE status = 'running' AND lease_until <= ?;",
365 )
366 .bind(now)
367 .bind(now)
368 .execute(database)
369 .await?
370 .rows_affected();
371
372 if reclaimed > 0 {
373 warn!(
374 event = "db_job_leases_reclaimed",
375 outcome = "advisory",
376 rows_reclaimed = reclaimed,
377 );
378 }
379 Ok(reclaimed)
380 }
381
382 pub async fn release_owned(runner_id: &str, database: &Database) -> Result<u64, sqlx::Error> {
387 let released = crate::sql::query(
388 "UPDATE jobs SET status = 'ready', lease_owner = NULL, lease_until = NULL, \
389 updated_at = ? WHERE status = 'running' AND lease_owner = ?;",
390 )
391 .bind(now_secs())
392 .bind(runner_id)
393 .execute(database)
394 .await?
395 .rows_affected();
396
397 if released > 0 {
398 debug!(
399 event = "db_job_leases_released",
400 outcome = "success",
401 rows_released = released,
402 );
403 }
404 Ok(released)
405 }
406
407 pub async fn find_by_id(id: Uuid, database: &Database) -> Result<Option<Self>, sqlx::Error> {
409 let mut query = crate::sql::Builder::new(
413 database.dialect(),
414 format!("SELECT {COLUMNS} FROM jobs WHERE id = "),
415 );
416 query.push_bind(id);
417 let row = query.build().fetch_optional(database).await?;
418 row.map(Self::from_row).transpose()
419 }
420
421 pub async fn find_live(
423 kind: &str,
424 dedup_key: &str,
425 database: &Database,
426 ) -> Result<Option<Self>, sqlx::Error> {
427 let mut query = crate::sql::Builder::new(
428 database.dialect(),
429 format!(
430 "SELECT {COLUMNS} FROM jobs \
431 WHERE status IN ('ready', 'running') AND kind = "
432 ),
433 );
434 query.push_bind(kind);
435 query.push(" AND dedup_key = ");
436 query.push_bind(dedup_key);
437 let row = query.build().fetch_optional(database).await?;
438 row.map(Self::from_row).transpose()
439 }
440
441 pub async fn count_live(kind: &str, database: &Database) -> Result<i64, sqlx::Error> {
448 let count: i64 = crate::sql::query(
449 "SELECT COUNT(*) FROM jobs WHERE status IN ('ready', 'running') AND kind = ?;",
450 )
451 .bind(kind)
452 .fetch_one(database)
453 .await?
454 .try_get(0usize)?;
455 Ok(count)
456 }
457
458 pub async fn search(
474 query: &JobQuery,
475 database: &Database,
476 ) -> Result<(Vec<Self>, i64), sqlx::Error> {
477 debug!(
478 event = "db_job_search_started",
479 outcome = "progress",
480 job_kind = ?query.kind,
481 status = ?query.status,
482 limit = query.limit,
483 offset = query.offset,
484 );
485
486 let mut page =
487 crate::sql::Builder::new(database.dialect(), format!("SELECT {COLUMNS} FROM jobs"));
488 query.push_predicates(&mut page);
489 page.push(" ORDER BY created_at DESC, id DESC LIMIT ");
493 page.push_bind(query.limit);
494 page.push(" OFFSET ");
495 page.push_bind(query.offset);
496
497 let rows = page.build().fetch_all(database).await?;
498 let jobs: Vec<Self> = rows
499 .into_iter()
500 .map(Self::from_row)
501 .collect::<Result<_, _>>()?;
502
503 let mut count = crate::sql::Builder::new(database.dialect(), "SELECT COUNT(*) FROM jobs");
504 query.push_predicates(&mut count);
505 let total: i64 = count.build().fetch_one(database).await?.try_get::<i64>(0)?;
506
507 Ok((jobs, total))
508 }
509
510 pub async fn find_latest_by_dedup(
517 kind: &str,
518 dedup_key: &str,
519 database: &Database,
520 ) -> Result<Option<Self>, sqlx::Error> {
521 let mut query = crate::sql::Builder::new(
522 database.dialect(),
523 format!("SELECT {COLUMNS} FROM jobs WHERE kind = "),
524 );
525 query.push_bind(kind);
526 query.push(" AND dedup_key = ");
527 query.push_bind(dedup_key);
528 query.push(" ORDER BY created_at DESC, id DESC LIMIT 1");
529 let row = query.build().fetch_optional(database).await?;
530 row.map(Self::from_row).transpose()
531 }
532
533 pub async fn cancel_row(
552 id: Uuid,
553 from: JobStatus,
554 database: &Database,
555 ) -> Result<Option<Self>, sqlx::Error> {
556 debug_assert_ne!(
557 from,
558 JobStatus::Running,
559 "a running job is the runner's; cancelling it would strand a lease"
560 );
561 let sql = format!(
562 "UPDATE jobs \
563 SET status = 'cancelled', updated_at = ?, lease_owner = NULL, lease_until = NULL \
564 WHERE id = ? AND status = ? \
565 RETURNING {COLUMNS};"
566 );
567 let row = crate::sql::query(sqlx::AssertSqlSafe(sql))
568 .bind(now_secs())
569 .bind(id)
570 .bind(from.as_str())
571 .fetch_optional(database)
572 .await?;
573 let job = row.map(Self::from_row).transpose()?;
574 if job.is_some() {
575 info!(event = "db_job_cancelled", outcome = "success", job_id = %id, from = from.as_str());
576 }
577 Ok(job)
578 }
579
580 pub async fn advance_row(id: Uuid, database: &Database) -> Result<Option<Self>, sqlx::Error> {
584 let now = now_secs();
585 let sql = format!(
586 "UPDATE jobs SET run_at = ?, updated_at = ? \
587 WHERE id = ? AND status = 'ready' RETURNING {COLUMNS};"
588 );
589 let row = crate::sql::query(sqlx::AssertSqlSafe(sql))
590 .bind(now)
591 .bind(now)
592 .bind(id)
593 .fetch_optional(database)
594 .await?;
595 let job = row.map(Self::from_row).transpose()?;
596 if job.is_some() {
597 info!(event = "db_job_advanced", outcome = "success", job_id = %id);
598 }
599 Ok(job)
600 }
601
602 pub async fn revive_row(id: Uuid, database: &Database) -> Result<Option<Self>, sqlx::Error> {
618 let now = now_secs();
619 let sql = format!(
620 "UPDATE jobs \
621 SET status = 'ready', run_at = ?, updated_at = ?, \
622 attempts = CASE WHEN max_attempts - 1 > 0 \
623 THEN max_attempts - 1 ELSE 0 END, \
624 lease_owner = NULL, lease_until = NULL \
625 WHERE id = ? AND status = 'failed' RETURNING {COLUMNS};"
626 );
627 let row = crate::sql::query(sqlx::AssertSqlSafe(sql))
628 .bind(now)
629 .bind(now)
630 .bind(id)
631 .fetch_optional(database)
632 .await?;
633 let job = row.map(Self::from_row).transpose()?;
634 if let Some(job) = &job {
635 info!(
636 event = "db_job_revived",
637 outcome = "success",
638 job_id = %id,
639 attempts = job.attempts,
640 );
641 }
642 Ok(job)
643 }
644
645 pub async fn cleanup(cutoff: i64, database: &Database) -> Result<u64, sqlx::Error> {
650 let deleted = crate::sql::query(
651 "DELETE FROM jobs \
652 WHERE status IN ('done', 'failed', 'cancelled') AND updated_at < ?;",
653 )
654 .bind(cutoff)
655 .execute(database)
656 .await?
657 .rows_affected();
658 info!(
659 event = "db_job_cleanup_completed",
660 outcome = "success",
661 rows_removed = deleted,
662 cutoff = cutoff,
663 );
664 Ok(deleted)
665 }
666}
667
668#[cfg(test)]
669mod tests {
670 use super::*;
671 use serde_json::json;
672 use std::sync::Arc;
673
674 async fn db() -> Arc<Database> {
675 Arc::new(Database::connect_for_test().await.unwrap())
676 }
677
678 fn job_id(name: &str) -> Uuid {
686 let mut bytes = [0u8; 16];
687 let name = name.as_bytes();
688 let take = name.len().min(16);
689 bytes[..take].copy_from_slice(&name[..take]);
690 Uuid::from_bytes(bytes)
691 }
692
693 async fn enqueue(id: Uuid, key: &str, run_at: i64, database: &Database) -> bool {
695 Job::enqueue(
696 NewJob {
697 id,
698 kind: "test",
699 dedup_key: key,
700 payload: &json!({"n": 1}),
701 run_at,
702 deadline: None,
703 max_attempts: 3,
704 },
705 database,
706 )
707 .await
708 .unwrap()
709 }
710
711 async fn claim(runner: &str, database: &Database) -> Option<Job> {
712 Job::claim_next(runner, &["test"], now_secs() + 60, now_secs(), database)
713 .await
714 .unwrap()
715 }
716
717 #[tokio::test]
718 async fn a_queued_job_round_trips_through_every_column() {
719 let database = db().await;
720 let deadline = now_secs() + 900;
721 assert!(
722 Job::enqueue(
723 NewJob {
724 id: job_id("job-1"),
725 kind: "relay",
726 dedup_key: "ord-1",
727 payload: &json!({"order_id": "ord-1"}),
728 run_at: 1_234,
729 deadline: Some(deadline),
730 max_attempts: 7,
731 },
732 &database,
733 )
734 .await
735 .unwrap()
736 );
737
738 let job = Job::find_by_id(job_id("job-1"), &database)
739 .await
740 .unwrap()
741 .unwrap();
742 assert_eq!(job.kind, "relay");
743 assert_eq!(job.dedup_key, "ord-1");
744 assert_eq!(job.payload, json!({"order_id": "ord-1"}));
745 assert_eq!(job.status, "ready");
746 assert_eq!(job.run_at, 1_234);
747 assert_eq!(job.attempts, 0);
748 assert_eq!(job.max_attempts, 7);
749 assert_eq!(job.deadline, Some(deadline));
750 assert!(job.lease_until.is_none());
751 assert!(job.lease_owner.is_none());
752 assert!(job.last_error.is_none());
753 }
754
755 #[tokio::test]
758 async fn a_live_job_holds_its_identity_and_a_settled_one_releases_it() {
759 let database = db().await;
760 assert!(enqueue(job_id("job-1"), "ord-1", now_secs(), &database).await);
761 assert!(
762 !enqueue(job_id("job-2"), "ord-1", now_secs(), &database).await,
763 "a second live job must not take an identity already held"
764 );
765
766 let job = claim("runner-a", &database).await.unwrap();
768 assert!(
769 !enqueue(job_id("job-3"), "ord-1", now_secs(), &database).await,
770 "'running' holds the identity exactly as 'ready' does"
771 );
772
773 Job::complete(job.id, "runner-a", &database).await.unwrap();
774 assert!(
775 enqueue(job_id("job-4"), "ord-1", now_secs(), &database).await,
776 "a settled job releases its identity"
777 );
778 }
779
780 #[tokio::test]
781 async fn claiming_takes_the_oldest_eligible_row_and_only_once() {
782 let database = db().await;
783 assert!(enqueue(job_id("job-new"), "b", now_secs() - 10, &database).await);
784 assert!(enqueue(job_id("job-old"), "a", now_secs() - 100, &database).await);
785
786 let first = claim("runner-a", &database).await.unwrap();
787 assert_eq!(first.id, job_id("job-old"), "oldest `run_at` first");
788 assert_eq!(first.attempts, 1, "the counter moves at claim");
789 assert_eq!(first.lease_owner.as_deref(), Some("runner-a"));
790
791 let second = claim("runner-b", &database).await.unwrap();
792 assert_eq!(second.id, job_id("job-new"));
793 assert!(
794 claim("runner-c", &database).await.is_none(),
795 "a claimed row is not claimable again"
796 );
797 }
798
799 #[tokio::test]
800 async fn claiming_skips_a_job_whose_run_at_has_not_arrived() {
801 let database = db().await;
802 assert!(enqueue(job_id("job-1"), "a", now_secs() + 3_600, &database).await);
803 assert!(claim("runner-a", &database).await.is_none());
804 }
805
806 #[tokio::test]
807 async fn claiming_skips_a_kind_the_runner_does_not_hold() {
808 let database = db().await;
809 assert!(enqueue(job_id("job-1"), "a", now_secs(), &database).await);
810
811 let claimed = Job::claim_next(
812 "runner-a",
813 &["something-else"],
814 now_secs() + 60,
815 now_secs(),
816 &database,
817 )
818 .await
819 .unwrap();
820 assert!(
821 claimed.is_none(),
822 "an unregistered kind is left alone, not mis-run"
823 );
824 }
825
826 #[tokio::test]
827 async fn claiming_with_no_registered_kinds_asks_the_database_nothing() {
828 let database = db().await;
829 assert!(enqueue(job_id("job-1"), "a", now_secs(), &database).await);
830 assert!(
831 Job::claim_next("runner-a", &[], now_secs() + 60, now_secs(), &database)
832 .await
833 .unwrap()
834 .is_none()
835 );
836 }
837
838 #[tokio::test]
841 async fn a_settlement_from_a_runner_that_lost_the_lease_writes_nothing() {
842 let database = db().await;
843 assert!(enqueue(job_id("job-1"), "a", now_secs(), &database).await);
844 let job = claim("runner-a", &database).await.unwrap();
845
846 for settled in [
847 Job::complete(job.id, "runner-b", &database).await.unwrap(),
848 Job::retry(job.id, "runner-b", now_secs(), "x", &database)
849 .await
850 .unwrap(),
851 Job::reschedule(job.id, "runner-b", now_secs(), &database)
852 .await
853 .unwrap(),
854 Job::abandon(job.id, "runner-b", "x", &database)
855 .await
856 .unwrap(),
857 ] {
858 assert!(!settled, "the lease guard must refuse a foreign settlement");
859 }
860
861 let after = Job::find_by_id(job_id("job-1"), &database)
862 .await
863 .unwrap()
864 .unwrap();
865 assert_eq!(after.status, "running");
866 assert_eq!(after.lease_owner.as_deref(), Some("runner-a"));
867 }
868
869 #[tokio::test]
870 async fn retry_returns_the_job_at_its_new_time_and_keeps_the_attempt_count() {
871 let database = db().await;
872 assert!(enqueue(job_id("job-1"), "a", now_secs(), &database).await);
873 let job = claim("runner-a", &database).await.unwrap();
874
875 assert!(
876 Job::retry(job.id, "runner-a", 9_999, "upstream timed out", &database)
877 .await
878 .unwrap()
879 );
880
881 let after = Job::find_by_id(job_id("job-1"), &database)
882 .await
883 .unwrap()
884 .unwrap();
885 assert_eq!(after.status, "ready");
886 assert_eq!(after.run_at, 9_999);
887 assert_eq!(after.attempts, 1, "a retry does not forgive the attempt");
888 assert_eq!(after.last_error.as_deref(), Some("upstream timed out"));
889 assert!(after.lease_owner.is_none());
890 }
891
892 #[tokio::test]
893 async fn reschedule_resets_the_attempt_count_and_clears_the_last_error() {
894 let database = db().await;
895 assert!(enqueue(job_id("job-1"), "a", now_secs(), &database).await);
896 let job = claim("runner-a", &database).await.unwrap();
897 Job::retry(job.id, "runner-a", now_secs(), "a failure", &database)
898 .await
899 .unwrap();
900 let job = claim("runner-a", &database).await.unwrap();
901 assert_eq!(job.attempts, 2);
902
903 assert!(
904 Job::reschedule(job.id, "runner-a", 5_000, &database)
905 .await
906 .unwrap()
907 );
908
909 let after = Job::find_by_id(job_id("job-1"), &database)
910 .await
911 .unwrap()
912 .unwrap();
913 assert_eq!(after.status, "ready");
914 assert_eq!(after.run_at, 5_000);
915 assert_eq!(after.attempts, 0, "a fresh occurrence starts fresh");
916 assert!(after.last_error.is_none());
917 }
918
919 #[tokio::test]
920 async fn abandon_is_terminal_and_records_why() {
921 let database = db().await;
922 assert!(enqueue(job_id("job-1"), "a", now_secs(), &database).await);
923 let job = claim("runner-a", &database).await.unwrap();
924
925 assert!(
926 Job::abandon(job.id, "runner-a", "the order no longer exists", &database)
927 .await
928 .unwrap()
929 );
930
931 let after = Job::find_by_id(job_id("job-1"), &database)
932 .await
933 .unwrap()
934 .unwrap();
935 assert_eq!(after.status, "failed");
936 assert_eq!(
937 after.last_error.as_deref(),
938 Some("the order no longer exists")
939 );
940 }
941
942 #[tokio::test]
943 async fn reclaim_takes_only_leases_that_have_expired() {
944 let database = db().await;
945 assert!(enqueue(job_id("job-live"), "a", now_secs(), &database).await);
946 assert!(enqueue(job_id("job-dead"), "b", now_secs(), &database).await);
947
948 Job::claim_next(
950 "runner-a",
951 &["test"],
952 now_secs() + 600,
953 now_secs(),
954 &database,
955 )
956 .await
957 .unwrap();
958 Job::claim_next("runner-a", &["test"], now_secs() - 1, now_secs(), &database)
959 .await
960 .unwrap();
961
962 assert_eq!(
963 Job::reclaim_expired(now_secs(), &database).await.unwrap(),
964 1
965 );
966
967 let reclaimed = Job::find_by_id(job_id("job-dead"), &database)
968 .await
969 .unwrap()
970 .unwrap();
971 assert_eq!(reclaimed.status, "ready");
972 assert!(reclaimed.lease_owner.is_none());
973 assert_eq!(
974 reclaimed.attempts, 1,
975 "the attempt was spent, and the row must keep saying so"
976 );
977
978 let held = Job::find_by_id(job_id("job-live"), &database)
979 .await
980 .unwrap()
981 .unwrap();
982 assert_eq!(held.status, "running");
983 }
984
985 #[tokio::test]
986 async fn release_owned_is_scoped_to_one_runner() {
987 let database = db().await;
988 assert!(enqueue(job_id("job-1"), "a", now_secs(), &database).await);
989 assert!(enqueue(job_id("job-2"), "b", now_secs(), &database).await);
990 claim("runner-a", &database).await.unwrap();
991 claim("runner-b", &database).await.unwrap();
992
993 assert_eq!(Job::release_owned("runner-a", &database).await.unwrap(), 1);
994 assert_eq!(
995 Job::find_by_id(job_id("job-1"), &database)
996 .await
997 .unwrap()
998 .unwrap()
999 .status,
1000 "ready"
1001 );
1002 assert_eq!(
1003 Job::find_by_id(job_id("job-2"), &database)
1004 .await
1005 .unwrap()
1006 .unwrap()
1007 .status,
1008 "running"
1009 );
1010 }
1011
1012 #[tokio::test]
1013 async fn find_live_sees_a_queued_job_and_not_a_settled_one() {
1014 let database = db().await;
1015 assert!(enqueue(job_id("job-1"), "ord-1", now_secs(), &database).await);
1016 assert!(
1017 Job::find_live("test", "ord-1", &database)
1018 .await
1019 .unwrap()
1020 .is_some()
1021 );
1022
1023 let job = claim("runner-a", &database).await.unwrap();
1024 Job::complete(job.id, "runner-a", &database).await.unwrap();
1025 assert!(
1026 Job::find_live("test", "ord-1", &database)
1027 .await
1028 .unwrap()
1029 .is_none()
1030 );
1031 }
1032
1033 #[tokio::test]
1034 async fn cleanup_removes_settled_rows_strictly_older_than_the_cutoff() {
1035 let database = db().await;
1036 assert!(enqueue(job_id("job-done"), "a", now_secs(), &database).await);
1037 assert!(enqueue(job_id("job-live"), "b", now_secs() + 3_600, &database).await);
1038 let job = claim("runner-a", &database).await.unwrap();
1039 Job::complete(job.id, "runner-a", &database).await.unwrap();
1040
1041 let settled = Job::find_by_id(job_id("job-done"), &database)
1046 .await
1047 .unwrap()
1048 .unwrap();
1049 assert_eq!(
1050 Job::cleanup(settled.updated_at, &database).await.unwrap(),
1051 0
1052 );
1053 assert_eq!(
1054 Job::cleanup(settled.updated_at + 1, &database)
1055 .await
1056 .unwrap(),
1057 1
1058 );
1059 assert!(
1060 Job::find_by_id(job_id("job-done"), &database)
1061 .await
1062 .unwrap()
1063 .is_none()
1064 );
1065 assert!(
1066 Job::find_by_id(job_id("job-live"), &database)
1067 .await
1068 .unwrap()
1069 .is_some(),
1070 "a job still queued is not old, however long ago it was written"
1071 );
1072 }
1073
1074 #[tokio::test]
1075 async fn an_unknown_status_is_refused_by_the_check_constraint() {
1076 let database = db().await;
1077 let error = crate::sql::query(
1078 "INSERT INTO jobs (id, kind, dedup_key, payload, status, run_at, attempts, \
1079 max_attempts, created_at, updated_at) \
1080 VALUES (?, 'test', 'k', '{}', 'halfway', 0, 0, 1, 0, 0);",
1081 )
1082 .bind(crate::id::mint())
1083 .execute(&database)
1084 .await
1085 .unwrap_err();
1086 assert!(crate::sql::is_check_violation(&error), "{error}");
1087 }
1088
1089 use crate::status::JobStatus;
1092
1093 async fn enqueue_kind(
1095 id: Uuid,
1096 kind: &str,
1097 key: &str,
1098 run_at: i64,
1099 max_attempts: i64,
1100 database: &Database,
1101 ) -> bool {
1102 Job::enqueue(
1103 NewJob {
1104 id,
1105 kind,
1106 dedup_key: key,
1107 payload: &json!({}),
1108 run_at,
1109 deadline: None,
1110 max_attempts,
1111 },
1112 database,
1113 )
1114 .await
1115 .unwrap()
1116 }
1117
1118 async fn failed_job_max(
1125 id: Uuid,
1126 kind: &str,
1127 key: &str,
1128 max_attempts: i64,
1129 database: &Database,
1130 ) -> Job {
1131 assert!(enqueue_kind(id, kind, key, now_secs(), max_attempts, database).await);
1132 crate::sql::query(
1133 "UPDATE jobs SET status = 'failed', attempts = 1, \
1134 last_error = 'upstream said no', updated_at = ? WHERE id = ?;",
1135 )
1136 .bind(now_secs())
1137 .bind(id)
1138 .execute(database)
1139 .await
1140 .unwrap();
1141 Job::find_by_id(id, database).await.unwrap().unwrap()
1142 }
1143
1144 async fn failed_job(id: Uuid, kind: &str, key: &str, database: &Database) -> Job {
1145 failed_job_max(id, kind, key, 3, database).await
1146 }
1147
1148 #[tokio::test]
1149 async fn search_filters_kind_and_status_and_pages_without_overlap() {
1150 let database = db().await;
1151 for i in 0..3 {
1153 assert!(
1154 enqueue_kind(
1155 job_id(&format!("relay-{i}")),
1156 "relay",
1157 &format!("ord-{i}"),
1158 now_secs() - i64::from(10 - i),
1159 3,
1160 &database,
1161 )
1162 .await
1163 );
1164 }
1165 for i in 0..2 {
1166 assert!(
1167 enqueue_kind(
1168 job_id(&format!("sweep-{i}")),
1169 "sweep",
1170 &format!("s-{i}"),
1171 now_secs(),
1172 3,
1173 &database,
1174 )
1175 .await
1176 );
1177 }
1178 failed_job(job_id("relay-failed"), "relay", "ord-f", &database).await;
1179
1180 let (rows, total) = Job::search(
1182 &JobQuery {
1183 kind: Some("relay".to_string()),
1184 limit: 50,
1185 ..JobQuery::default()
1186 },
1187 &database,
1188 )
1189 .await
1190 .unwrap();
1191 assert_eq!(total, 4);
1192 assert_eq!(rows.len(), 4);
1193
1194 let (rows, total) = Job::search(
1196 &JobQuery {
1197 kind: Some("relay".to_string()),
1198 status: Some(JobStatus::Failed),
1199 limit: 50,
1200 offset: 0,
1201 },
1202 &database,
1203 )
1204 .await
1205 .unwrap();
1206 assert_eq!(total, 1);
1207 assert_eq!(rows[0].id, job_id("relay-failed"));
1208
1209 let mut seen = std::collections::BTreeSet::new();
1212 for offset in 0..4 {
1213 let (rows, total) = Job::search(
1214 &JobQuery {
1215 kind: Some("relay".to_string()),
1216 limit: 1,
1217 offset,
1218 ..JobQuery::default()
1219 },
1220 &database,
1221 )
1222 .await
1223 .unwrap();
1224 assert_eq!(total, 4);
1225 assert_eq!(rows.len(), 1);
1226 assert!(seen.insert(rows[0].id), "a row appeared on two pages");
1227 }
1228 assert_eq!(seen.len(), 4);
1229 }
1230
1231 #[tokio::test]
1232 async fn a_kind_filter_value_is_bound_not_interpolated() {
1233 let database = db().await;
1234 assert!(enqueue_kind(job_id("job-1"), "relay", "k", now_secs(), 3, &database).await);
1235 let (rows, total) = Job::search(
1236 &JobQuery {
1237 kind: Some("' OR 1=1 --".to_string()),
1238 limit: 50,
1239 ..JobQuery::default()
1240 },
1241 &database,
1242 )
1243 .await
1244 .unwrap();
1245 assert_eq!(total, 0);
1246 assert!(rows.is_empty());
1247 assert!(
1249 Job::find_by_id(job_id("job-1"), &database)
1250 .await
1251 .unwrap()
1252 .is_some()
1253 );
1254 }
1255
1256 #[tokio::test]
1257 async fn cancel_row_moves_ready_and_failed_and_refuses_the_rest() {
1258 let database = db().await;
1259
1260 assert!(enqueue_kind(job_id("ready"), "test", "a", now_secs(), 3, &database).await);
1262 let cancelled = Job::cancel_row(job_id("ready"), JobStatus::Ready, &database)
1263 .await
1264 .unwrap()
1265 .expect("a ready job cancels");
1266 assert_eq!(cancelled.status, "cancelled");
1267 assert!(
1269 Job::cancel_row(job_id("ready"), JobStatus::Ready, &database)
1270 .await
1271 .unwrap()
1272 .is_none()
1273 );
1274
1275 failed_job(job_id("failed"), "test", "b", &database).await;
1277 assert!(
1278 Job::cancel_row(job_id("failed"), JobStatus::Failed, &database)
1279 .await
1280 .unwrap()
1281 .is_some()
1282 );
1283
1284 assert!(enqueue_kind(job_id("running"), "test", "c", now_secs(), 3, &database).await);
1286 Job::claim_next("r", &["test"], now_secs() + 60, now_secs(), &database)
1287 .await
1288 .unwrap();
1289 assert!(
1290 Job::cancel_row(job_id("running"), JobStatus::Ready, &database)
1291 .await
1292 .unwrap()
1293 .is_none()
1294 );
1295 assert_eq!(
1296 Job::find_by_id(job_id("running"), &database)
1297 .await
1298 .unwrap()
1299 .unwrap()
1300 .status,
1301 "running"
1302 );
1303
1304 assert!(enqueue_kind(job_id("done"), "test", "d", now_secs(), 3, &database).await);
1306 let job = Job::claim_next("r", &["test"], now_secs() + 60, now_secs(), &database)
1307 .await
1308 .unwrap()
1309 .unwrap();
1310 Job::complete(job.id, "r", &database).await.unwrap();
1311 assert!(
1312 Job::cancel_row(job_id("done"), JobStatus::Ready, &database)
1313 .await
1314 .unwrap()
1315 .is_none()
1316 );
1317 }
1318
1319 #[tokio::test]
1320 async fn advance_row_nudges_a_ready_job_and_refuses_a_running_one() {
1321 let database = db().await;
1322 assert!(
1323 enqueue_kind(
1324 job_id("ready"),
1325 "test",
1326 "a",
1327 now_secs() + 3_600,
1328 3,
1329 &database
1330 )
1331 .await
1332 );
1333 let advanced = Job::advance_row(job_id("ready"), &database)
1334 .await
1335 .unwrap()
1336 .expect("a ready job advances");
1337 assert!(advanced.run_at <= now_secs() + 1);
1338 assert_eq!(advanced.attempts, 0, "advancing does not spend an attempt");
1339
1340 Job::claim_next("r", &["test"], now_secs() + 60, now_secs(), &database)
1342 .await
1343 .unwrap();
1344 assert!(
1345 Job::advance_row(job_id("ready"), &database)
1346 .await
1347 .unwrap()
1348 .is_none()
1349 );
1350
1351 failed_job(job_id("failed"), "test", "b", &database).await;
1352 assert!(
1353 Job::advance_row(job_id("failed"), &database)
1354 .await
1355 .unwrap()
1356 .is_none()
1357 );
1358 }
1359
1360 #[tokio::test]
1361 async fn revive_row_sets_attempts_to_max_minus_one_and_refuses_a_live_job() {
1362 let database = db().await;
1363
1364 let failed = failed_job(job_id("failed"), "test", "a", &database).await;
1365 assert_eq!(failed.max_attempts, 3);
1366 let revived = Job::revive_row(job_id("failed"), &database)
1367 .await
1368 .unwrap()
1369 .expect("a failed job revives");
1370 assert_eq!(revived.status, "ready");
1371 assert_eq!(revived.attempts, 2, "exactly one attempt left");
1372 assert!(revived.run_at <= now_secs() + 1);
1373 assert_eq!(
1374 revived.last_error.as_deref(),
1375 Some("upstream said no"),
1376 "the last error stays as the record of why it stopped"
1377 );
1378
1379 assert!(enqueue_kind(job_id("ready"), "test", "b", now_secs(), 3, &database).await);
1381 assert!(
1382 Job::revive_row(job_id("ready"), &database)
1383 .await
1384 .unwrap()
1385 .is_none()
1386 );
1387
1388 let one = failed_job_max(job_id("one"), "test", "c", 1, &database).await;
1390 assert_eq!(one.max_attempts, 1);
1391 let revived = Job::revive_row(job_id("one"), &database)
1392 .await
1393 .unwrap()
1394 .unwrap();
1395 assert_eq!(revived.attempts, 0);
1396 }
1397
1398 #[tokio::test]
1399 async fn find_latest_by_dedup_sees_a_settled_job_where_find_live_does_not() {
1400 let database = db().await;
1401 assert!(enqueue_kind(job_id("job-1"), "relay", "ord-1", now_secs(), 3, &database).await);
1402 let job = Job::claim_next("r", &["relay"], now_secs() + 60, now_secs(), &database)
1403 .await
1404 .unwrap()
1405 .unwrap();
1406 Job::complete(job.id, "r", &database).await.unwrap();
1407
1408 assert!(
1409 Job::find_live("relay", "ord-1", &database)
1410 .await
1411 .unwrap()
1412 .is_none()
1413 );
1414 let latest = Job::find_latest_by_dedup("relay", "ord-1", &database)
1415 .await
1416 .unwrap()
1417 .expect("the settled job is still resolvable");
1418 assert_eq!(latest.id, job_id("job-1"));
1419 assert_eq!(latest.status, "done");
1420 }
1421
1422 #[tokio::test]
1423 async fn revive_then_a_second_failure_stays_failed_without_another_nudge() {
1424 let database = db().await;
1425 failed_job(job_id("j"), "test", "a", &database).await;
1426 Job::revive_row(job_id("j"), &database)
1427 .await
1428 .unwrap()
1429 .unwrap();
1430 let job = Job::claim_next("r", &["test"], now_secs() + 60, now_secs(), &database)
1432 .await
1433 .unwrap()
1434 .unwrap();
1435 assert_eq!(job.attempts, 3);
1436 Job::abandon(job.id, "r", "again", &database).await.unwrap();
1437
1438 let after = Job::find_by_id(job_id("j"), &database)
1439 .await
1440 .unwrap()
1441 .unwrap();
1442 assert_eq!(after.status, "failed");
1443 assert!(
1445 Job::advance_row(job_id("j"), &database)
1446 .await
1447 .unwrap()
1448 .is_none()
1449 );
1450 assert!(
1451 Job::revive_row(job_id("j"), &database)
1452 .await
1453 .unwrap()
1454 .is_some()
1455 );
1456 }
1457}