1use std::{
141 collections::{HashMap, HashSet},
142 fmt,
143 future::Future,
144 pin::Pin,
145 sync::Arc,
146 time::Duration,
147};
148
149use sqlx::{PgConnection, PgExecutor, PgPool, Postgres, Row, Transaction, postgres::PgPoolOptions};
150use tokio::sync::{OwnedSemaphorePermit, Semaphore, watch};
151use uuid::Uuid;
152
153pub use sqlx::types::chrono::{DateTime, Utc};
154
155pub type Result<T, E = Error> = std::result::Result<T, E>;
157
158#[derive(Debug)]
160#[non_exhaustive]
161pub enum Error {
162 Database(sqlx::Error),
164 Encode(serde_json::Error),
166 Job(String),
168 NotACode {
171 what: &'static str,
173 },
174}
175
176impl fmt::Display for Error {
177 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
178 match self {
179 Self::Database(error) => error.fmt(f),
180 Self::Encode(error) => error.fmt(f),
181 Self::Job(text) => write!(f, "worker error: {text}"),
182 Self::NotACode { what } => write!(
183 f,
184 "`{what}` must be 1 to {MAX_REASON_CHARS} characters with no line breaks"
185 ),
186 }
187 }
188}
189
190impl std::error::Error for Error {
191 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
192 match self {
193 Self::Database(error) => Some(error),
194 Self::Encode(error) => Some(error),
195 Self::Job(_) | Self::NotACode { .. } => None,
196 }
197 }
198}
199
200impl From<sqlx::Error> for Error {
201 fn from(error: sqlx::Error) -> Self {
202 Self::Database(error)
203 }
204}
205
206impl From<serde_json::Error> for Error {
207 fn from(error: serde_json::Error) -> Self {
208 Self::Encode(error)
209 }
210}
211
212type JobFuture = Pin<Box<dyn Future<Output = Result<(), String>> + Send>>;
213type Handler = Arc<dyn Fn(serde_json::Value) -> JobFuture + Send + Sync>;
214
215const SETUP: &str = "CREATE TABLE IF NOT EXISTS axa_queue (
216 id UUID PRIMARY KEY,
217 name TEXT NOT NULL,
218 args JSONB NOT NULL,
219 status TEXT NOT NULL DEFAULT 'queued',
220 error TEXT,
221 run_at TIMESTAMPTZ NOT NULL DEFAULT now(),
222 started_at TIMESTAMPTZ,
223 created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
224 updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
225)";
226const SETUP_INDEX: &str =
227 "CREATE INDEX IF NOT EXISTS axa_queue_ready ON axa_queue (run_at) WHERE status = 'queued'";
228const SETUP_ATTEMPTS: &str =
230 "ALTER TABLE axa_queue ADD COLUMN IF NOT EXISTS attempts INTEGER NOT NULL DEFAULT 0";
231const SETUP_MAX_ATTEMPTS: &str =
233 "ALTER TABLE axa_queue ADD COLUMN IF NOT EXISTS max_attempts INTEGER";
234const SETUP_PRIORITY: &str =
236 "ALTER TABLE axa_queue ADD COLUMN IF NOT EXISTS priority SMALLINT NOT NULL DEFAULT 0";
237const SETUP_TIMEOUT: &str = "ALTER TABLE axa_queue ADD COLUMN IF NOT EXISTS timeout_ms BIGINT";
239const SETUP_CLAIM_INDEX: &str = "CREATE INDEX IF NOT EXISTS axa_queue_claim
240 ON axa_queue (priority DESC, run_at) WHERE status = 'queued'";
241const SETUP_SCHEDULE: &str = "CREATE TABLE IF NOT EXISTS axa_schedule (
242 name TEXT PRIMARY KEY,
243 due_at TIMESTAMPTZ NOT NULL,
244 last_job UUID,
245 interval_ms BIGINT,
246 seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
247 archived_at TIMESTAMPTZ,
248 created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
249 updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
250)";
251const SETUP_ACTS: &str = "CREATE TABLE IF NOT EXISTS axa_job_acts (
256 id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
257 job_id UUID NOT NULL,
258 job_name TEXT NOT NULL,
259 act TEXT NOT NULL CHECK (act IN ('cancelled', 'retried', 'released')),
260 attempts INTEGER NOT NULL,
261 by TEXT NOT NULL CHECK (length(by) BETWEEN 1 AND 200 AND by !~ '[[:cntrl:]]'),
262 reason TEXT NOT NULL CHECK (length(reason) BETWEEN 1 AND 200 AND reason !~ '[[:cntrl:]]'),
263 at TIMESTAMPTZ NOT NULL DEFAULT now()
264)";
265const SETUP_ACTS_INDEX: &str =
266 "CREATE INDEX IF NOT EXISTS axa_job_acts_job ON axa_job_acts (job_id, id)";
267const SETUP_ACTS_REFUSE: &str = "CREATE OR REPLACE FUNCTION axa_job_acts_refuse() RETURNS trigger
269LANGUAGE plpgsql AS $$
270BEGIN
271 RAISE EXCEPTION '% on % is refused: acts on jobs are only ever added to',
272 TG_OP, TG_TABLE_NAME;
273END
274$$";
275const SETUP_ACTS_ROWS: &str = "CREATE OR REPLACE TRIGGER axa_job_acts_refuse
276 BEFORE UPDATE OR DELETE ON axa_job_acts
277 FOR EACH ROW EXECUTE FUNCTION axa_job_acts_refuse()";
278const SETUP_ACTS_TRUNCATE: &str = "CREATE OR REPLACE TRIGGER axa_job_acts_refuse_truncate
279 BEFORE TRUNCATE ON axa_job_acts
280 FOR EACH STATEMENT EXECUTE FUNCTION axa_job_acts_refuse()";
281const SETUP_ACTS_ALWAYS: &str = "ALTER TABLE axa_job_acts
284 ENABLE ALWAYS TRIGGER axa_job_acts_refuse,
285 ENABLE ALWAYS TRIGGER axa_job_acts_refuse_truncate";
286const ACTS_TRIGGERS: [&str; 2] = ["axa_job_acts_refuse", "axa_job_acts_refuse_truncate"];
288const MISSING_TRIGGERS: &str = "SELECT want FROM unnest($1::text[]) AS want
289 WHERE NOT EXISTS (
290 SELECT 1 FROM pg_trigger t
291 WHERE t.tgrelid = to_regclass('axa_job_acts') AND t.tgname = want
292 AND t.tgenabled::text = 'A'
293 )";
294const SETUP_LOCK: i64 = 0x0061_7861_5F71;
296
297const ENQUEUE: &str = "INSERT INTO axa_queue
298 (id, name, args, max_attempts, priority, run_at, timeout_ms)
299 VALUES ($1, $2, $3, $4, $5, COALESCE($6, now() + make_interval(secs => $7)), $8)";
300const CLAIM: &str = "UPDATE axa_queue
301 SET status = 'running', started_at = now(), updated_at = now(), attempts = attempts + 1
302 WHERE id = (
303 SELECT id FROM axa_queue
304 WHERE status = 'queued' AND run_at <= now() AND name = ANY($1)
305 ORDER BY priority DESC, run_at
306 FOR UPDATE SKIP LOCKED
307 LIMIT 1
308 )
309 RETURNING id, name, args, attempts, max_attempts, timeout_ms";
310const HEARTBEAT: &str = "UPDATE axa_queue SET updated_at = now()
313 WHERE id = $1 AND attempts = $2 AND status = 'running'";
314const FINISH: &str = "UPDATE axa_queue SET status = $3, error = $4, updated_at = now()
315 WHERE id = $1 AND attempts = $2 AND status = 'running'";
316const RETRY: &str = "UPDATE axa_queue
317 SET status = 'queued', error = $3, started_at = NULL, updated_at = now(),
318 run_at = now() + make_interval(secs => $4)
319 WHERE id = $1 AND attempts = $2 AND status = 'running'";
320const REQUEUE_STALE: &str = "UPDATE axa_queue
321 SET status = 'queued', started_at = NULL, updated_at = now()
322 WHERE status = 'running' AND updated_at < now() - make_interval(mins => $1)
323 AND attempts < COALESCE(max_attempts, $2)";
324const FAIL_STALE: &str = "UPDATE axa_queue
325 SET status = 'failed', error = 'abandoned by its worker', updated_at = now()
326 WHERE status = 'running' AND updated_at < now() - make_interval(mins => $1)
327 AND attempts >= COALESCE(max_attempts, $2)";
328const PURGE: &str = "DELETE FROM axa_queue
329 WHERE status IN ('completed', 'failed', 'cancelled')
330 AND updated_at < now() - make_interval(days => $1)";
331const STATUS: &str = "SELECT id, name, status, attempts, max_attempts, priority, error,
332 timeout_ms, run_at, started_at, created_at, updated_at FROM axa_queue WHERE id = $1";
333const MARK_LOST: &str =
337 "UPDATE axa_queue SET status = 'needs_review', error = $2, updated_at = now()
338 WHERE id = $1 AND status IN ('queued', 'running', 'failed')";
339const CANCEL: &str = "WITH changed AS (
342 UPDATE axa_queue SET status = 'cancelled', updated_at = now()
343 WHERE id = $1 AND status = 'queued'
344 RETURNING id, name, attempts
345 )
346 INSERT INTO axa_job_acts (job_id, job_name, act, attempts, by, reason)
347 SELECT id, name, 'cancelled', attempts, $2, $3 FROM changed";
348const RETRY_FAILED: &str = "WITH target AS (
353 SELECT id, status FROM axa_queue
354 WHERE id = $1 AND status IN ('failed', 'needs_review')
355 FOR UPDATE
356 ), changed AS (
357 UPDATE axa_queue q
358 SET status = 'queued', run_at = now(), started_at = NULL, updated_at = now(),
359 max_attempts = q.attempts + 1
360 FROM target WHERE q.id = target.id
361 RETURNING q.id, q.name, q.attempts, target.status AS was
362 )
363 INSERT INTO axa_job_acts (job_id, job_name, act, attempts, by, reason)
364 SELECT id, name, CASE was WHEN 'needs_review' THEN 'released' ELSE 'retried' END,
365 attempts, $2, $3
366 FROM changed";
367const ACTS: &str = "SELECT id, job_id, job_name, act, attempts, by, reason, at
368 FROM axa_job_acts WHERE job_id = $1 ORDER BY id";
369const COUNTS: &str = "SELECT
370 count(*) FILTER (WHERE status = 'queued' AND run_at <= now()) AS ready,
371 count(*) FILTER (WHERE status = 'queued' AND run_at > now()) AS waiting,
372 count(*) FILTER (WHERE status = 'running') AS running,
373 count(*) FILTER (WHERE status = 'completed') AS completed,
374 count(*) FILTER (WHERE status = 'failed') AS failed,
375 count(*) FILTER (WHERE status = 'cancelled') AS cancelled,
376 count(*) FILTER (WHERE status = 'needs_review') AS needs_review,
377 EXTRACT(EPOCH FROM now() - min(run_at) FILTER (WHERE status = 'queued' AND run_at <= now()))::float8 AS oldest
378 FROM axa_queue";
379const SCHEDULE_CREATE: &str = "INSERT INTO axa_schedule (name, due_at, interval_ms)
383 VALUES ($1, COALESCE($2, now()), $3)
384 ON CONFLICT (name) DO UPDATE
385 SET seen_at = now(), archived_at = NULL, interval_ms = EXCLUDED.interval_ms
386 WHERE axa_schedule.archived_at IS NOT NULL
387 OR axa_schedule.interval_ms IS DISTINCT FROM EXCLUDED.interval_ms
388 OR axa_schedule.seen_at < now() - interval '1 minute'";
389const SCHEDULE_LOCK: &str = "SELECT last_job, due_at <= now() AS due FROM axa_schedule
390 WHERE name = $1 AND archived_at IS NULL FOR UPDATE SKIP LOCKED";
391const SCHEDULE_ARCHIVE: &str = "UPDATE axa_schedule SET archived_at = now(), updated_at = now()
395 WHERE archived_at IS NULL AND name <> ALL($1)
396 AND seen_at < now() - make_interval(mins => $2)";
397const SCHEDULES: &str = "SELECT s.name, s.interval_ms, s.due_at, s.last_job, s.seen_at,
398 s.archived_at, s.created_at, q.status AS last_status, q.error AS last_error
399 FROM axa_schedule s LEFT JOIN axa_queue q ON q.id = s.last_job ORDER BY s.name";
400const SCHEDULE_BUSY: &str = "SELECT EXISTS (SELECT 1 FROM axa_queue
401 WHERE id = $1 AND status IN ('queued', 'running'))";
402const SCHEDULE_ADVANCE: &str = "UPDATE axa_schedule
405 SET due_at = due_at + make_interval(secs => $2)
406 * (floor(EXTRACT(EPOCH FROM now() - due_at)::float8 / $2) + 1),
407 last_job = COALESCE($3, last_job), updated_at = now()
408 WHERE name = $1";
409
410pub const SETUP_STATEMENTS: [&str; 14] = [
414 SETUP,
415 SETUP_ATTEMPTS,
416 SETUP_MAX_ATTEMPTS,
417 SETUP_INDEX,
418 SETUP_PRIORITY,
419 SETUP_TIMEOUT,
420 SETUP_CLAIM_INDEX,
421 SETUP_SCHEDULE,
422 SETUP_ACTS,
423 SETUP_ACTS_INDEX,
424 SETUP_ACTS_REFUSE,
425 SETUP_ACTS_ROWS,
426 SETUP_ACTS_TRUNCATE,
427 SETUP_ACTS_ALWAYS,
428];
429
430pub const MAX_REASON_CHARS: usize = 200;
432
433const MIN_HEARTBEAT: Duration = Duration::from_millis(20);
435
436const MAX_BACKOFF: Duration = Duration::from_hours(1);
438
439const MAX_ERROR: usize = 1024;
441
442#[must_use]
444pub fn backoff(base: Duration, attempts: i32) -> Duration {
445 let doublings = u32::try_from(attempts.saturating_sub(1))
446 .unwrap_or(0)
447 .min(16);
448 base.saturating_mul(1 << doublings).min(MAX_BACKOFF)
449}
450
451fn cut(mut text: String) -> String {
453 if text.len() > MAX_ERROR {
454 let mut end = MAX_ERROR;
455 while !text.is_char_boundary(end) {
456 end -= 1;
457 }
458 text.truncate(end);
459 }
460 text
461}
462
463fn millis(duration: Duration) -> i64 {
464 i64::try_from(duration.as_millis()).unwrap_or(i64::MAX)
465}
466
467fn from_millis(millis: i64) -> Duration {
468 Duration::from_millis(u64::try_from(millis).unwrap_or(0))
469}
470
471#[derive(Debug, Clone, Copy, PartialEq, Eq)]
473enum When {
474 Now,
475 At(DateTime<Utc>),
476 In(Duration),
477}
478
479#[derive(Clone)]
481pub struct NewJob {
482 name: String,
483 args: serde_json::Value,
484 max_attempts: Option<i32>,
485 priority: i16,
486 when: When,
487 timeout: Option<Duration>,
488}
489
490impl fmt::Debug for NewJob {
491 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
492 f.debug_struct("NewJob")
493 .field("name", &self.name)
494 .field("max_attempts", &self.max_attempts)
495 .field("priority", &self.priority)
496 .field("when", &self.when)
497 .field("timeout", &self.timeout)
498 .finish_non_exhaustive()
499 }
500}
501
502impl NewJob {
503 pub fn new(name: impl Into<String>, args: serde_json::Value) -> Self {
506 Self {
507 name: name.into(),
508 args,
509 max_attempts: None,
510 priority: 0,
511 when: When::Now,
512 timeout: None,
513 }
514 }
515
516 #[must_use]
519 pub const fn timeout(mut self, limit: Duration) -> Self {
520 self.timeout = Some(limit);
521 self
522 }
523
524 #[must_use]
526 pub const fn max_attempts(mut self, max_attempts: Option<i32>) -> Self {
527 self.max_attempts = max_attempts;
528 self
529 }
530
531 #[must_use]
534 pub const fn priority(mut self, priority: i16) -> Self {
535 self.priority = priority;
536 self
537 }
538
539 #[must_use]
541 pub const fn run_in(mut self, delay: Duration) -> Self {
542 self.when = When::In(delay);
543 self
544 }
545
546 #[must_use]
548 pub const fn run_at(mut self, at: DateTime<Utc>) -> Self {
549 self.when = When::At(at);
550 self
551 }
552
553 #[must_use]
555 pub fn name(&self) -> &str {
556 &self.name
557 }
558}
559
560pub async fn enqueue_on<'e, E: PgExecutor<'e>>(executor: E, job: &NewJob) -> Result<Uuid> {
567 let id = Uuid::new_v4();
568 let (at, delay) = match job.when {
569 When::Now => (None, 0.0),
570 When::At(at) => (Some(at), 0.0),
571 When::In(delay) => (None, delay.as_secs_f64()),
572 };
573 sqlx::query(ENQUEUE)
574 .bind(id)
575 .bind(&job.name)
576 .bind(&job.args)
577 .bind(job.max_attempts)
578 .bind(job.priority)
579 .bind(at)
580 .bind(delay)
581 .bind(job.timeout.map(millis))
582 .execute(executor)
583 .await?;
584 Ok(id)
585}
586
587pub async fn enqueue_all_on(connection: &mut PgConnection, jobs: &[NewJob]) -> Result<Vec<Uuid>> {
594 let mut ids = Vec::with_capacity(jobs.len());
595 for job in jobs {
596 ids.push(enqueue_on(&mut *connection, job).await?);
597 }
598 Ok(ids)
599}
600
601fn check_code(text: &str, what: &'static str) -> Result<()> {
603 if text.is_empty()
604 || text.chars().count() > MAX_REASON_CHARS
605 || text.chars().any(char::is_control)
606 {
607 Err(Error::NotACode { what })
608 } else {
609 Ok(())
610 }
611}
612
613pub async fn cancel(
627 transaction: &mut Transaction<'_, Postgres>,
628 id: Uuid,
629 by: &str,
630 reason: &str,
631) -> Result<bool> {
632 act(transaction, CANCEL, id, by, reason).await
633}
634
635pub async fn retry(
648 transaction: &mut Transaction<'_, Postgres>,
649 id: Uuid,
650 by: &str,
651 reason: &str,
652) -> Result<bool> {
653 act(transaction, RETRY_FAILED, id, by, reason).await
654}
655
656async fn act(
658 transaction: &mut Transaction<'_, Postgres>,
659 statement: &'static str,
660 id: Uuid,
661 by: &str,
662 reason: &str,
663) -> Result<bool> {
664 check_code(by, "by")?;
665 check_code(reason, "reason")?;
666 let kept = sqlx::query(statement)
667 .bind(id)
668 .bind(by)
669 .bind(reason)
670 .execute(&mut **transaction)
671 .await?
672 .rows_affected();
673 Ok(kept == 1)
674}
675
676#[derive(Debug, Clone, Copy, PartialEq, Eq)]
678#[non_exhaustive]
679pub enum ActKind {
680 Cancelled,
682 Retried,
684 Released,
686}
687
688impl ActKind {
689 fn parse(text: &str) -> Result<Self> {
690 match text {
691 "cancelled" => Ok(Self::Cancelled),
692 "retried" => Ok(Self::Retried),
693 "released" => Ok(Self::Released),
694 _ => Err(Error::Job("an act with an unknown kind".to_string())),
695 }
696 }
697}
698
699#[derive(Debug, Clone)]
702#[non_exhaustive]
703pub struct Act {
704 pub id: i64,
706 pub job_id: Uuid,
708 pub job_name: String,
710 pub kind: ActKind,
712 pub attempts: i32,
714 pub by: String,
716 pub reason: String,
718 pub at: DateTime<Utc>,
720}
721
722#[derive(Debug, Clone, Copy, PartialEq, Eq)]
724pub enum JobState {
725 Queued,
727 Running,
729 Completed,
731 Failed,
733 Cancelled,
735 NeedsReview,
739}
740
741impl JobState {
742 fn parse(text: &str) -> Result<Self> {
743 match text {
744 "queued" => Ok(Self::Queued),
745 "running" => Ok(Self::Running),
746 "completed" => Ok(Self::Completed),
747 "failed" => Ok(Self::Failed),
748 "cancelled" => Ok(Self::Cancelled),
749 "needs_review" => Ok(Self::NeedsReview),
750 _ => Err(Error::Job("a job row with an unknown status".to_string())),
751 }
752 }
753}
754
755#[derive(Debug, Clone)]
757#[non_exhaustive]
758pub struct JobInfo {
759 pub id: Uuid,
761 pub name: String,
763 pub state: JobState,
765 pub attempts: i32,
767 pub max_attempts: Option<i32>,
769 pub priority: i16,
771 pub timeout: Option<Duration>,
773 pub error: Option<String>,
776 pub run_at: DateTime<Utc>,
778 pub started_at: Option<DateTime<Utc>>,
780 pub created_at: DateTime<Utc>,
782 pub updated_at: DateTime<Utc>,
784}
785
786#[derive(Debug, Clone, Copy, Default, PartialEq)]
788#[non_exhaustive]
789pub struct Counts {
790 pub ready: i64,
792 pub waiting: i64,
794 pub running: i64,
796 pub completed: i64,
798 pub failed: i64,
800 pub cancelled: i64,
802 pub needs_review: i64,
805 pub oldest_ready_seconds: f64,
808}
809
810#[derive(Clone)]
812pub struct Schedule {
813 name: String,
814 interval: Duration,
815 first: Option<DateTime<Utc>>,
816 job: NewJob,
817}
818
819impl fmt::Debug for Schedule {
820 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
821 f.debug_struct("Schedule")
822 .field("name", &self.name)
823 .field("interval", &self.interval)
824 .field("first", &self.first)
825 .field("job", &self.job)
826 .finish()
827 }
828}
829
830impl Schedule {
831 pub fn every(name: impl Into<String>, interval: Duration, job: NewJob) -> Self {
840 let name = name.into();
841 assert!(
842 !name.is_empty() && name.len() <= 200,
843 "a schedule name must be 1 to 200 characters"
844 );
845 assert!(
846 interval >= Duration::from_secs(1),
847 "the schedule {name} needs an interval of at least a second"
848 );
849 Self {
850 name,
851 interval,
852 first: None,
853 job: NewJob {
854 when: When::Now,
855 ..job
856 },
857 }
858 }
859
860 #[must_use]
864 pub const fn first_at(mut self, first: DateTime<Utc>) -> Self {
865 self.first = Some(first);
866 self
867 }
868
869 #[must_use]
871 pub fn name(&self) -> &str {
872 &self.name
873 }
874}
875
876#[derive(Debug, Clone)]
878#[non_exhaustive]
879pub struct ScheduleInfo {
880 pub name: String,
882 pub interval: Option<Duration>,
884 pub due_at: DateTime<Utc>,
886 pub last_job: Option<Uuid>,
888 pub last_state: Option<JobState>,
890 pub last_error: Option<String>,
892 pub seen_at: DateTime<Utc>,
894 pub archived_at: Option<DateTime<Utc>>,
897 pub created_at: DateTime<Utc>,
899}
900
901#[derive(Debug, Clone)]
903pub struct QueueOptions {
904 pub workers: usize,
906 pub poll_interval: Duration,
908 pub stale_minutes: i32,
911 pub heartbeat_interval: Duration,
913 pub max_attempts: i32,
916 pub retry_backoff: Duration,
918 pub retention_days: i32,
920 pub maintenance_interval: Duration,
922}
923
924impl Default for QueueOptions {
925 fn default() -> Self {
926 Self {
927 workers: 2,
928 poll_interval: Duration::from_secs(1),
929 stale_minutes: 60,
930 heartbeat_interval: Duration::from_mins(1),
931 max_attempts: 1,
932 retry_backoff: Duration::from_secs(30),
933 retention_days: 7,
934 maintenance_interval: Duration::from_mins(5),
935 }
936 }
937}
938
939pub struct Queue {
941 pool: PgPool,
942 handlers: HashMap<String, Handler>,
943 limits: HashMap<String, Arc<Semaphore>>,
944 schedules: Vec<Schedule>,
945 options: QueueOptions,
946}
947
948impl fmt::Debug for Queue {
949 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
950 f.debug_struct("Queue")
951 .field("handlers", &self.handlers.len())
952 .field("limits", &self.limits.len())
953 .field("schedules", &self.schedules.len())
954 .field("options", &self.options)
955 .finish_non_exhaustive()
956 }
957}
958
959struct Claimed {
961 id: Uuid,
962 name: String,
963 args: serde_json::Value,
964 attempts: i32,
965 limit: i32,
966 timeout: Option<Duration>,
967}
968
969enum Outcome {
971 Done(Result<(), String>),
973 Lost(String),
975}
976
977impl Queue {
978 #[must_use]
981 pub fn from_pool(pool: PgPool, options: QueueOptions) -> Self {
982 Self {
983 pool,
984 handlers: HashMap::new(),
985 limits: HashMap::new(),
986 schedules: Vec::new(),
987 options,
988 }
989 }
990
991 async fn open(uri: &str, options: QueueOptions) -> Result<Self> {
993 let loops = u32::try_from(options.workers).unwrap_or(u32::MAX);
994 let pool = PgPoolOptions::new()
995 .max_connections(loops.saturating_add(2))
996 .connect(uri)
997 .await?;
998 Ok(Self::from_pool(pool, options))
999 }
1000
1001 pub async fn connect_existing(uri: &str, options: QueueOptions) -> Result<Self> {
1011 let queue = Self::open(uri, options).await?;
1012 queue.check().await?;
1013 Ok(queue)
1014 }
1015
1016 pub async fn connect(uri: &str, options: QueueOptions) -> Result<Self> {
1023 let queue = Self::open(uri, options).await?;
1024 let mut setup = queue.pool.begin().await?;
1029 sqlx::query("SELECT pg_advisory_xact_lock($1)")
1030 .bind(SETUP_LOCK)
1031 .execute(&mut *setup)
1032 .await?;
1033 for statement in SETUP_STATEMENTS {
1034 sqlx::query(statement).execute(&mut *setup).await?;
1035 }
1036 setup.commit().await?;
1037 Ok(queue)
1038 }
1039
1040 pub async fn check(&self) -> Result<()> {
1048 sqlx::query("SELECT attempts, max_attempts, priority, timeout_ms FROM axa_queue LIMIT 0")
1049 .execute(&self.pool)
1050 .await?;
1051 sqlx::query(
1052 "SELECT name, due_at, last_job, interval_ms, seen_at, archived_at FROM axa_schedule LIMIT 0",
1053 )
1054 .execute(&self.pool)
1055 .await?;
1056 sqlx::query(
1057 "SELECT id, job_id, job_name, act, attempts, by, reason, at FROM axa_job_acts LIMIT 0",
1058 )
1059 .execute(&self.pool)
1060 .await?;
1061 let missing: Vec<String> = sqlx::query_scalar(MISSING_TRIGGERS)
1062 .bind(ACTS_TRIGGERS.to_vec())
1063 .fetch_all(&self.pool)
1064 .await?;
1065 if missing.is_empty() {
1066 Ok(())
1067 } else {
1068 Err(Error::Database(sqlx::Error::Configuration(
1069 format!(
1070 "the job queue is missing, or has turned off, {}: run SETUP_STATEMENTS",
1071 missing.join(", ")
1072 )
1073 .into(),
1074 )))
1075 }
1076 }
1077
1078 #[must_use]
1081 pub const fn pool(&self) -> &PgPool {
1082 &self.pool
1083 }
1084
1085 #[must_use]
1087 pub const fn options(&self) -> &QueueOptions {
1088 &self.options
1089 }
1090
1091 pub fn handle<F, Fut, E>(&mut self, name: impl Into<String>, handler: F)
1094 where
1095 F: Fn(serde_json::Value) -> Fut + Send + Sync + 'static,
1096 Fut: Future<Output = std::result::Result<(), E>> + Send + 'static,
1097 E: fmt::Display + 'static,
1098 {
1099 let handler = Arc::new(handler);
1100 let run: Handler = Arc::new(move |args| {
1101 let job = handler(args);
1102 Box::pin(async move { job.await.map_err(|error| error.to_string()) })
1103 });
1104 self.handlers.insert(name.into(), run);
1105 }
1106
1107 pub fn limit(&mut self, name: impl Into<String>, jobs: usize) {
1110 self.limits
1111 .insert(name.into(), Arc::new(Semaphore::new(jobs.max(1))));
1112 }
1113
1114 pub fn schedule(&mut self, schedule: Schedule) {
1120 assert!(
1121 self.schedules.iter().all(|s| s.name != schedule.name),
1122 "the schedule {} was added twice",
1123 schedule.name
1124 );
1125 self.schedules.push(schedule);
1126 }
1127
1128 pub async fn enqueue(&self, name: &str, args: serde_json::Value) -> Result<Uuid> {
1134 enqueue_on(&self.pool, &NewJob::new(name, args)).await
1135 }
1136
1137 pub async fn enqueue_limited(
1143 &self,
1144 name: &str,
1145 args: serde_json::Value,
1146 max_attempts: Option<i32>,
1147 ) -> Result<Uuid> {
1148 enqueue_on(
1149 &self.pool,
1150 &NewJob::new(name, args).max_attempts(max_attempts),
1151 )
1152 .await
1153 }
1154
1155 pub async fn enqueue_job(&self, job: &NewJob) -> Result<Uuid> {
1161 enqueue_on(&self.pool, job).await
1162 }
1163
1164 pub async fn enqueue_all(&self, jobs: &[NewJob]) -> Result<Vec<Uuid>> {
1170 let mut transaction = self.pool.begin().await?;
1171 let ids = enqueue_all_on(&mut transaction, jobs).await?;
1172 transaction.commit().await?;
1173 Ok(ids)
1174 }
1175
1176 pub async fn job(&self, id: Uuid) -> Result<Option<JobInfo>> {
1183 let Some(row) = sqlx::query(STATUS)
1184 .bind(id)
1185 .fetch_optional(&self.pool)
1186 .await?
1187 else {
1188 return Ok(None);
1189 };
1190 Ok(Some(JobInfo {
1191 id: row.try_get("id")?,
1192 name: row.try_get("name")?,
1193 state: JobState::parse(row.try_get::<&str, _>("status")?)?,
1194 attempts: row.try_get("attempts")?,
1195 max_attempts: row.try_get("max_attempts")?,
1196 priority: row.try_get("priority")?,
1197 timeout: row
1198 .try_get::<Option<i64>, _>("timeout_ms")?
1199 .map(from_millis),
1200 error: row.try_get("error")?,
1201 run_at: row.try_get("run_at")?,
1202 started_at: row.try_get("started_at")?,
1203 created_at: row.try_get("created_at")?,
1204 updated_at: row.try_get("updated_at")?,
1205 }))
1206 }
1207
1208 pub async fn acts(&self, id: Uuid) -> Result<Vec<Act>> {
1216 let rows = sqlx::query(ACTS).bind(id).fetch_all(&self.pool).await?;
1217 rows.iter()
1218 .map(|row| {
1219 Ok(Act {
1220 id: row.try_get("id")?,
1221 job_id: row.try_get("job_id")?,
1222 job_name: row.try_get("job_name")?,
1223 kind: ActKind::parse(row.try_get::<&str, _>("act")?)?,
1224 attempts: row.try_get("attempts")?,
1225 by: row.try_get("by")?,
1226 reason: row.try_get("reason")?,
1227 at: row.try_get("at")?,
1228 })
1229 })
1230 .collect()
1231 }
1232
1233 pub async fn counts(&self) -> Result<Counts> {
1239 let row = sqlx::query(COUNTS).fetch_one(&self.pool).await?;
1240 Ok(Counts {
1241 ready: row.try_get("ready")?,
1242 waiting: row.try_get("waiting")?,
1243 running: row.try_get("running")?,
1244 completed: row.try_get("completed")?,
1245 failed: row.try_get("failed")?,
1246 cancelled: row.try_get("cancelled")?,
1247 needs_review: row.try_get("needs_review")?,
1248 oldest_ready_seconds: row.try_get::<Option<f64>, _>("oldest")?.unwrap_or(0.0),
1249 })
1250 }
1251
1252 pub async fn ping(&self) -> Result<()> {
1258 sqlx::query("SELECT 1").execute(&self.pool).await?;
1259 Ok(())
1260 }
1261
1262 pub async fn requeue_stale(&self) -> Result<u64> {
1269 sqlx::query(FAIL_STALE)
1270 .bind(self.options.stale_minutes)
1271 .bind(self.options.max_attempts)
1272 .execute(&self.pool)
1273 .await?;
1274 let result = sqlx::query(REQUEUE_STALE)
1275 .bind(self.options.stale_minutes)
1276 .bind(self.options.max_attempts)
1277 .execute(&self.pool)
1278 .await?;
1279 Ok(result.rows_affected())
1280 }
1281
1282 pub async fn purge_finished(&self) -> Result<u64> {
1289 let result = sqlx::query(PURGE)
1290 .bind(self.options.retention_days)
1291 .execute(&self.pool)
1292 .await?;
1293 Ok(result.rows_affected())
1294 }
1295
1296 pub async fn tick_schedules(&self) -> Result<usize> {
1304 let mut queued = 0;
1305 for schedule in &self.schedules {
1306 if self.tick(schedule).await? {
1307 queued += 1;
1308 }
1309 }
1310 Ok(queued)
1311 }
1312
1313 async fn tick(&self, schedule: &Schedule) -> Result<bool> {
1315 sqlx::query(SCHEDULE_CREATE)
1318 .bind(&schedule.name)
1319 .bind(schedule.first)
1320 .bind(millis(schedule.interval))
1321 .execute(&self.pool)
1322 .await?;
1323 let mut transaction = self.pool.begin().await?;
1324 let Some(row) = sqlx::query(SCHEDULE_LOCK)
1326 .bind(&schedule.name)
1327 .fetch_optional(&mut *transaction)
1328 .await?
1329 else {
1330 return Ok(false);
1331 };
1332 if !row.try_get::<bool, _>("due")? {
1333 transaction.commit().await?;
1334 return Ok(false);
1335 }
1336 let busy = match row.try_get::<Option<Uuid>, _>("last_job")? {
1337 Some(last) => {
1338 sqlx::query_scalar::<_, bool>(SCHEDULE_BUSY)
1339 .bind(last)
1340 .fetch_one(&mut *transaction)
1341 .await?
1342 }
1343 None => false,
1344 };
1345 let job = if busy {
1346 tracing::info!(schedule = %schedule.name, "schedule skipped, its last job has not finished");
1347 None
1348 } else {
1349 Some(enqueue_on(&mut *transaction, &schedule.job).await?)
1350 };
1351 sqlx::query(SCHEDULE_ADVANCE)
1352 .bind(&schedule.name)
1353 .bind(schedule.interval.as_secs_f64())
1354 .bind(job)
1355 .execute(&mut *transaction)
1356 .await?;
1357 transaction.commit().await?;
1358 if job.is_some() {
1359 tracing::debug!(schedule = %schedule.name, "schedule queued its job");
1360 }
1361 Ok(job.is_some())
1362 }
1363
1364 pub async fn archive_schedules(&self) -> Result<u64> {
1371 let names: Vec<&str> = self.schedules.iter().map(|s| s.name.as_str()).collect();
1372 let result = sqlx::query(SCHEDULE_ARCHIVE)
1373 .bind(&names)
1374 .bind(self.options.stale_minutes)
1375 .execute(&self.pool)
1376 .await?;
1377 Ok(result.rows_affected())
1378 }
1379
1380 pub async fn schedules(&self) -> Result<Vec<ScheduleInfo>> {
1386 let rows = sqlx::query(SCHEDULES).fetch_all(&self.pool).await?;
1387 rows.iter()
1388 .map(|row| {
1389 Ok(ScheduleInfo {
1390 name: row.try_get("name")?,
1391 interval: row
1392 .try_get::<Option<i64>, _>("interval_ms")?
1393 .map(from_millis),
1394 due_at: row.try_get("due_at")?,
1395 last_job: row.try_get("last_job")?,
1396 last_state: row
1397 .try_get::<Option<&str>, _>("last_status")?
1398 .map(JobState::parse)
1399 .transpose()?,
1400 last_error: row.try_get("last_error")?,
1401 seen_at: row.try_get("seen_at")?,
1402 archived_at: row.try_get("archived_at")?,
1403 created_at: row.try_get("created_at")?,
1404 })
1405 })
1406 .collect()
1407 }
1408
1409 async fn maintain(&self) {
1412 match self.archive_schedules().await {
1413 Ok(0) => {}
1414 Ok(archived) => tracing::info!(archived, "archived schedules no longer in code"),
1415 Err(error) => tracing::error!(%error, "could not archive schedules"),
1416 }
1417 if let Err(error) = self.requeue_stale().await {
1418 tracing::error!(%error, "could not recover stale jobs");
1419 }
1420 if let Err(error) = self.purge_finished().await {
1421 tracing::error!(%error, "could not delete old jobs");
1422 }
1423 }
1424
1425 async fn supervise(&self, job: JobFuture, claim: &Claimed) -> Outcome {
1429 let mut job = AbortOnDrop(tokio::spawn(job));
1430 let every = self.options.heartbeat_interval.max(MIN_HEARTBEAT);
1431 let stale =
1433 Duration::from_secs(u64::try_from(self.options.stale_minutes).unwrap_or(0) * 60);
1434 let mut last_heard = tokio::time::Instant::now();
1435 let limit = claim
1436 .timeout
1437 .map(|timeout| tokio::time::Instant::now() + timeout);
1438 let deadline = async {
1439 match limit {
1440 Some(at) => tokio::time::sleep_until(at).await,
1441 None => std::future::pending().await,
1442 }
1443 };
1444 tokio::pin!(deadline);
1445 loop {
1446 tokio::select! {
1447 finished = &mut job.0 => {
1448 return Outcome::Done(finished.unwrap_or_else(|_| {
1449 Err(Error::Job("the job panicked".to_string()).to_string())
1450 }));
1451 }
1452 () = &mut deadline => {
1453 let limit = claim.timeout.unwrap_or_default();
1454 return Outcome::Done(Err(
1455 Error::Job(format!("timed out after {}s", limit.as_secs_f64())).to_string(),
1456 ));
1457 }
1458 () = tokio::time::sleep(every) => {
1459 let beat = sqlx::query(HEARTBEAT)
1460 .bind(claim.id)
1461 .bind(claim.attempts)
1462 .execute(&self.pool)
1463 .await;
1464 match beat {
1465 Ok(done) if done.rows_affected() == 1 => last_heard = tokio::time::Instant::now(),
1466 Ok(_) => return Outcome::Lost("another worker or recovery took its claim".to_string()),
1467 Err(error) => {
1468 tracing::warn!(id = %claim.id, %error, "job heartbeat failed");
1469 if last_heard.elapsed() >= stale {
1470 return Outcome::Lost(format!(
1471 "it could not report in for {} minutes",
1472 self.options.stale_minutes
1473 ));
1474 }
1475 }
1476 }
1477 }
1478 }
1479 }
1480 }
1481
1482 async fn mark_lost(&self, claim: &Claimed, reason: &str) {
1485 let text = Error::Job(format!("stopped: {reason}")).to_string();
1486 let every = self.options.heartbeat_interval.max(MIN_HEARTBEAT);
1487 loop {
1488 match sqlx::query(MARK_LOST)
1489 .bind(claim.id)
1490 .bind(&text)
1491 .execute(&self.pool)
1492 .await
1493 {
1494 Ok(_) => return,
1495 Err(error) => {
1496 tracing::error!(id = %claim.id, %error, "could not mark a lost job for review");
1497 tokio::time::sleep(every).await;
1498 }
1499 }
1500 }
1501 }
1502
1503 fn claimable(&self) -> (Vec<String>, HashMap<String, OwnedSemaphorePermit>) {
1506 let mut places = HashMap::new();
1507 let mut names = Vec::new();
1508 for name in self.handlers.keys() {
1509 match self.limits.get(name) {
1510 None => names.push(name.clone()),
1511 Some(limit) => {
1512 if let Ok(place) = limit.clone().try_acquire_owned() {
1513 places.insert(name.clone(), place);
1514 names.push(name.clone());
1515 }
1516 }
1517 }
1518 }
1519 (names, places)
1520 }
1521
1522 async fn claim(&self, names: &[String]) -> Result<Option<Claimed>> {
1523 let Some(row) = sqlx::query(CLAIM)
1524 .bind(names)
1525 .fetch_optional(&self.pool)
1526 .await?
1527 else {
1528 return Ok(None);
1529 };
1530 Ok(Some(Claimed {
1531 id: row.try_get("id")?,
1532 name: row.try_get("name")?,
1533 args: row.try_get("args")?,
1534 attempts: row.try_get("attempts")?,
1535 limit: row
1536 .try_get::<Option<i32>, _>("max_attempts")?
1537 .unwrap_or(self.options.max_attempts),
1538 timeout: row
1539 .try_get::<Option<i64>, _>("timeout_ms")?
1540 .map(from_millis),
1541 }))
1542 }
1543
1544 pub async fn run_one(&self) -> Result<bool> {
1551 let (names, mut places) = self.claimable();
1552 if names.is_empty() {
1553 return Ok(false);
1554 }
1555 let Some(job) = self.claim(&names).await? else {
1556 return Ok(false);
1557 };
1558 let _place = places.remove(&job.name);
1560 drop(places);
1561 let outcome = match self.handlers.get(&job.name) {
1562 Some(handler) => self.supervise(handler(job.args.clone()), &job).await,
1563 None => Outcome::Done(Err(
1564 Error::Job(format!("no handler for job {}", job.name)).to_string()
1565 )),
1566 };
1567 let (id, name, attempts) = (job.id, job.name.as_str(), job.attempts);
1568 let finished = match outcome {
1569 Outcome::Lost(reason) => {
1570 tracing::error!(job = %name, %id, attempts, %reason, "job stopped, it lost its claim");
1571 self.mark_lost(&job, &reason).await;
1572 return Ok(true);
1573 }
1574 Outcome::Done(Ok(())) => {
1575 sqlx::query(FINISH)
1576 .bind(id)
1577 .bind(attempts)
1578 .bind("completed")
1579 .bind(None::<String>)
1580 .execute(&self.pool)
1581 .await?
1582 }
1583 Outcome::Done(Err(error)) if attempts < job.limit => {
1584 tracing::warn!(job = %name, %id, attempts, %error, "job failed, will retry");
1585 let wait = backoff(self.options.retry_backoff, attempts);
1586 sqlx::query(RETRY)
1587 .bind(id)
1588 .bind(attempts)
1589 .bind(cut(error))
1590 .bind(wait.as_secs_f64())
1591 .execute(&self.pool)
1592 .await?
1593 }
1594 Outcome::Done(Err(error)) => {
1595 tracing::error!(job = %name, %id, attempts, %error, "job failed");
1596 sqlx::query(FINISH)
1597 .bind(id)
1598 .bind(attempts)
1599 .bind("failed")
1600 .bind(cut(error))
1601 .execute(&self.pool)
1602 .await?
1603 }
1604 };
1605 if finished.rows_affected() == 0 {
1606 tracing::error!(job = %name, %id, "job finished after losing its claim");
1609 self.mark_lost(
1610 &job,
1611 "it finished after another worker or recovery took its claim",
1612 )
1613 .await;
1614 }
1615 Ok(true)
1616 }
1617
1618 pub async fn run(self: Arc<Self>, shutdown: watch::Receiver<bool>) {
1624 let mut loops = Vec::new();
1625 loops.push(AbortOnDrop(tokio::spawn(every_until(
1626 shutdown.clone(),
1627 self.options.maintenance_interval,
1628 {
1629 let queue = self.clone();
1630 move || {
1631 let queue = queue.clone();
1632 async move { queue.maintain().await }
1633 }
1634 },
1635 ))));
1636 if !self.schedules.is_empty() {
1637 let queue = self.clone();
1638 loops.push(AbortOnDrop(tokio::spawn(every_until(
1639 shutdown.clone(),
1640 self.options.poll_interval,
1641 move || {
1642 let queue = queue.clone();
1643 async move {
1644 if let Err(error) = queue.tick_schedules().await {
1645 tracing::error!(%error, "could not queue scheduled jobs");
1646 }
1647 }
1648 },
1649 ))));
1650 }
1651 for _ in 0..self.options.workers.max(1) {
1652 let queue = self.clone();
1653 let mut shutdown = shutdown.clone();
1654 loops.push(AbortOnDrop(tokio::spawn(async move {
1655 while !*shutdown.borrow() {
1656 let worked = queue.run_one().await.unwrap_or_else(|error| {
1657 tracing::error!(%error, "job queue error");
1658 false
1659 });
1660 if !worked {
1661 tokio::select! {
1662 () = tokio::time::sleep(queue.options.poll_interval) => {},
1663 _ = shutdown.changed() => {},
1664 }
1665 }
1666 }
1667 })));
1668 }
1669 for job_loop in &mut loops {
1670 let _ = (&mut job_loop.0).await;
1671 }
1672 }
1673
1674 #[must_use]
1676 pub fn handled(&self) -> HashSet<&str> {
1677 self.handlers.keys().map(String::as_str).collect()
1678 }
1679}
1680
1681async fn every_until<F, Fut>(mut shutdown: watch::Receiver<bool>, gap: Duration, round: F)
1683where
1684 F: Fn() -> Fut,
1685 Fut: Future<Output = ()>,
1686{
1687 while !*shutdown.borrow() {
1688 round().await;
1689 tokio::select! {
1690 () = tokio::time::sleep(gap) => {},
1691 _ = shutdown.changed() => {},
1692 }
1693 }
1694}
1695
1696struct AbortOnDrop<T>(tokio::task::JoinHandle<T>);
1699
1700impl<T> Drop for AbortOnDrop<T> {
1701 fn drop(&mut self) {
1702 self.0.abort();
1703 }
1704}
1705
1706#[cfg(test)]
1707mod tests {
1708 use super::*;
1709
1710 #[test]
1711 fn each_retry_waits_twice_as_long_up_to_a_limit() {
1712 let base = Duration::from_secs(30);
1713 assert_eq!(backoff(base, 1), base);
1714 assert_eq!(backoff(base, 2), base * 2);
1715 assert_eq!(backoff(base, 3), base * 4);
1716 assert_eq!(backoff(base, 1_000), MAX_BACKOFF);
1717 }
1718
1719 #[test]
1720 fn the_debug_text_leaves_out_arguments() {
1721 let job = NewJob::new("Report", serde_json::json!({ "nhs": "943 476 5919" }));
1722 let schedule = Schedule::every("nightly", Duration::from_hours(1), job.clone());
1723 for text in [format!("{job:?}"), format!("{schedule:?}")] {
1724 assert!(text.contains("Report"), "{text}");
1725 assert!(!text.contains("943"), "{text}");
1726 }
1727 }
1728
1729 #[test]
1730 fn a_by_or_reason_that_is_not_a_code_is_refused() {
1731 assert!(check_code("ops:alice", "by").is_ok());
1732 assert!(check_code(&"é".repeat(MAX_REASON_CHARS), "reason").is_ok());
1733 for bad in [
1734 String::new(),
1735 "é".repeat(MAX_REASON_CHARS + 1),
1736 "two\nlines".to_string(),
1737 "a\ttab".to_string(),
1738 ] {
1739 let error = check_code(&bad, "reason").unwrap_err();
1740 assert!(matches!(error, Error::NotACode { what: "reason" }));
1741 assert!(!error.to_string().contains("lines"), "the text is left out");
1742 }
1743 }
1744
1745 #[test]
1746 fn a_long_error_is_cut_on_a_character_boundary() {
1747 let text = "é".repeat(MAX_ERROR);
1748 let kept = cut(text);
1749 assert!(kept.len() <= MAX_ERROR);
1750 assert!(kept.chars().all(|c| c == 'é'));
1751 assert_eq!(cut("short".to_string()), "short");
1752 }
1753
1754 #[test]
1755 #[should_panic(expected = "at least a second")]
1756 fn a_schedule_under_a_second_is_refused_at_boot() {
1757 let _ = Schedule::every(
1758 "fast",
1759 Duration::from_millis(10),
1760 NewJob::new("J", serde_json::Value::Null),
1761 );
1762 }
1763
1764 #[test]
1765 #[should_panic(expected = "1 to 200 characters")]
1766 fn a_schedule_with_no_name_is_refused_at_boot() {
1767 let _ = Schedule::every(
1768 "",
1769 Duration::from_secs(1),
1770 NewJob::new("J", serde_json::Value::Null),
1771 );
1772 }
1773
1774 #[tokio::test]
1775 #[should_panic(expected = "added twice")]
1776 async fn a_schedule_added_twice_is_refused_at_boot() {
1777 let pool = PgPoolOptions::new()
1778 .connect_lazy("postgres://u@localhost/none")
1779 .unwrap();
1780 let mut queue = Queue::from_pool(pool, QueueOptions::default());
1781 let job = NewJob::new("J", serde_json::Value::Null);
1782 queue.schedule(Schedule::every("a", Duration::from_secs(5), job.clone()));
1783 queue.schedule(Schedule::every("a", Duration::from_secs(9), job));
1784 }
1785
1786 #[test]
1787 fn a_schedule_ignores_a_delay_on_its_job() {
1788 let job = NewJob::new("J", serde_json::Value::Null).run_in(Duration::from_hours(1));
1789 let schedule = Schedule::every("s", Duration::from_secs(5), job);
1790 assert_eq!(schedule.job.when, When::Now);
1791 }
1792}