Skip to main content

axa_postgres_job_queue/
lib.rs

1//! A Postgres job queue, worker pool, and recurring schedules.
2//!
3//! Jobs live in one `axa_queue` table. Each job loop claims the ready job with the
4//! highest priority, oldest first, in a single statement using `FOR UPDATE SKIP
5//! LOCKED`, so two loops can never claim the same row and no broker is needed.
6//!
7//! ```no_run
8//! use std::time::Duration;
9//! use axa_postgres_job_queue::{NewJob, Queue, QueueOptions, Schedule};
10//! use tokio::sync::watch;
11//!
12//! # async fn example(uri: &str) -> axa_postgres_job_queue::Result<()> {
13//! let mut queue = Queue::connect(uri, QueueOptions::default()).await?;
14//! queue.handle("Report", |args| async move {
15//!     let id = args["id"].as_i64().ok_or("no id")?;
16//!     println!("report {id}");
17//!     Ok::<(), &str>(())
18//! });
19//! queue.limit("Report", 1); // at most one at a time in this process
20//! queue.schedule(Schedule::every(
21//!     "nightly-report",
22//!     Duration::from_hours(24),
23//!     NewJob::new("Report", serde_json::json!({ "id": 0 })),
24//! ));
25//!
26//! let id = queue
27//!     .enqueue_job(&NewJob::new("Report", serde_json::json!({ "id": 7 })).run_in(Duration::from_mins(5)))
28//!     .await?;
29//! let queue = std::sync::Arc::new(queue);
30//! let (stop, stopped) = watch::channel(false);
31//! let jobs = tokio::spawn(queue.clone().run(stopped));
32//! // ... serve until told to stop, then:
33//! let _ = stop.send(true);
34//! let _ = jobs.await;
35//! # let _ = id;
36//! # Ok(())
37//! # }
38//! ```
39//!
40//! A job moves from `queued` to `running` to `completed` or `failed`, with the
41//! failure text kept on the row. A queued job can be `cancelled`.
42//!
43//! # A person's acts
44//!
45//! A job row is state: it changes as the job runs, and retention deletes it. What a
46//! person does to a job is a record. [`cancel()`] stops a queued job, and [`retry()`]
47//! queues a failed one again or releases one from review. Each takes the caller's
48//! transaction and a `by` and `reason`, and appends the act to `axa_job_acts` in the
49//! same statement as the change, so the act, the change, and the service's own audit
50//! row commit together or not at all. There is no form that takes a pool. An act that
51//! does not apply, such as cancelling a running job, changes nothing and writes no
52//! act.
53//!
54//! ```no_run
55//! # async fn example(queue: &axa_postgres_job_queue::Queue, id: uuid::Uuid) -> axa_postgres_job_queue::Result<()> {
56//! let mut transaction = queue.pool().begin().await?;
57//! let cancelled = axa_postgres_job_queue::cancel(&mut transaction, id, "ops:alice", "customer_asked").await?;
58//! // ... write the service's own audit row on the same transaction ...
59//! transaction.commit().await?;
60//! # let _ = cancelled;
61//! # Ok(())
62//! # }
63//! ```
64//!
65//! `by` and `reason` are codes, not content: at most [`MAX_REASON_CHARS`] characters
66//! with no line breaks, and the table refuses anything else. `axa_job_acts` is only
67//! ever inserted into, and its triggers refuse `UPDATE`, `DELETE`, and `TRUNCATE`,
68//! whoever sends them. An act names its job by id, with no foreign key, so it outlives
69//! the job's row. [`Queue::acts`] reads a job's acts.
70//!
71//! # Attempts
72//!
73//! A job gets [`QueueOptions::max_attempts`] tries, unless it was queued with its
74//! own limit. The default is one, because a retry repeats the job's work, and only
75//! its author knows that is safe. Each retry waits twice as long as the last,
76//! starting from [`QueueOptions::retry_backoff`]. A job that panics counts as
77//! failed, and its loop carries on.
78//!
79//! # Lost jobs
80//!
81//! A running job reports in every [`QueueOptions::heartbeat_interval`], so a job
82//! may run for as long as it needs. One not heard from for
83//! [`QueueOptions::stale_minutes`] is assumed lost with its worker. If it has
84//! attempts left it is queued again, and otherwise it is marked failed, so a crash
85//! can never loop for ever. Each claim is identified by its attempt, so a run that
86//! was given up on cannot then overwrite the result of its replacement.
87//!
88//! # How many at once
89//!
90//! A process runs [`QueueOptions::workers`] job loops, so at most that many jobs at
91//! once. [`Queue::limit`] caps one job type below that, in this process. A limit is
92//! per process and not across replicas: three processes with a limit of one may run
93//! three. Work that must never overlap anywhere needs a lock of its own.
94//!
95//! # When a job runs
96//!
97//! [`NewJob::run_in`] and [`NewJob::run_at`] delay a job, and [`NewJob::priority`]
98//! puts it ahead of others that are ready. Delay is measured on the database's
99//! clock. A delayed job is claimed only once it is due, and then about as soon as a
100//! loop next polls.
101//!
102//! # Schedules
103//!
104//! A [`Schedule`] queues a job every interval. Each due time becomes one ordinary
105//! job, so its retries, status, limit, and recovery are the queue's. The schedule's
106//! row in `axa_schedule` is locked while its job is queued, in the same
107//! transaction, so across any number of replicas each due time is queued once.
108//!
109//! - **No drift.** The next time is counted from when the last was due, not from
110//!   when it ran.
111//! - **Missed times run once.** After every process was down, one job is queued,
112//!   and the schedule keeps its place: the next time is still a whole number of
113//!   intervals from the first.
114//! - **No stacking.** When the schedule's last job is still queued or running at a
115//!   due time, that time is skipped.
116//! - **The first time** is [`Schedule::first_at`], or as soon as the row is made.
117//!   It applies only when the row is made, so a schedule every 24 hours first due
118//!   at 02:00 runs at 02:00 each day, on the database's clock in UTC.
119//!
120//! [`Queue::run`] ticks the schedules on every poll, and a service with no job
121//! loops can call [`Queue::tick_schedules`] itself. A schedule's job type must be
122//! handled by some worker, or its jobs wait. A schedule removed from code leaves a
123//! row that nothing reads.
124//!
125//! # Queuing inside a transaction
126//!
127//! [`enqueue_on`] takes the caller's transaction, so a job and the record it is
128//! about commit together or not at all. [`Queue::enqueue`] uses its own
129//! connection, so a job queued that way can outlive a rollback.
130//!
131//! # Personal data
132//!
133//! The `args` column is plain JSONB, and finished rows are deleted after
134//! [`QueueOptions::retention_days`], so a job table does not become a second copy
135//! of the data. Prefer an id to a snapshot, and never put a secret in arguments.
136//! [`JobInfo`] leaves the arguments out. Its error text is whatever the job's
137//! error said, so it is for operators, and is not to be sent to a client. An act
138//! keeps no arguments either, and nothing logs its `by` or `reason`.
139
140use 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
155/// What this crate's functions return.
156pub type Result<T, E = Error> = std::result::Result<T, E>;
157
158/// What went wrong.
159#[derive(Debug)]
160#[non_exhaustive]
161pub enum Error {
162    /// The database refused or could not be reached.
163    Database(sqlx::Error),
164    /// A job's arguments could not be encoded.
165    Encode(serde_json::Error),
166    /// A job failed, or the queue met something it cannot read.
167    Job(String),
168    /// Who or why, given for a person's act, is empty, longer than
169    /// [`MAX_REASON_CHARS`], or has a line break.
170    NotACode {
171        /// Which it was: `by` or `reason`.
172        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'";
228/// Tables created by axa 0.1.0 have no `attempts` column.
229const SETUP_ATTEMPTS: &str =
230    "ALTER TABLE axa_queue ADD COLUMN IF NOT EXISTS attempts INTEGER NOT NULL DEFAULT 0";
231/// A job's own limit on tries, when it has one.
232const SETUP_MAX_ATTEMPTS: &str =
233    "ALTER TABLE axa_queue ADD COLUMN IF NOT EXISTS max_attempts INTEGER";
234/// Higher runs first. Tables created before axa-postgres-job-queue have none.
235const SETUP_PRIORITY: &str =
236    "ALTER TABLE axa_queue ADD COLUMN IF NOT EXISTS priority SMALLINT NOT NULL DEFAULT 0";
237/// A job's own time limit, when it has one.
238const 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)";
251/// A person's acts on jobs. This table is a record (P-7): it is only ever inserted
252/// into, and its triggers refuse every change. A job row is state, and retention
253/// deletes it, so an act names its job by id with no foreign key, and keeps the job's
254/// name and attempts as they were when the act was done.
255const 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)";
267/// Refuses whatever fired it, whoever sent it.
268const 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()";
281/// `ENABLE ALWAYS` keeps the refusals on when `session_replication_role` is
282/// `replica`, which otherwise turns triggers off.
283const 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";
286/// The refusing triggers [`Queue::check`] requires, each set to fire always.
287const 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    )";
294/// Advisory lock key for table setup: `axa_q` as bytes.
295const 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";
310/// `attempts` identifies this run of the job, so a run that was given up on
311/// finds no row here and changes nothing.
312const 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";
333/// A run that lost its claim stops, and its job waits for a person. Another run
334/// may hold the row by now, and this stops that one too, at its next report.
335/// A row already completed is left alone.
336const 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')";
339/// Cancel a queued job and keep the act, in one statement. A job in any other state
340/// changes nothing and writes no act.
341const 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";
348/// A retried job gets one more try. `attempts` keeps rising, as it identifies
349/// each run, so a run given up on still cannot write over the new one. The act is
350/// `released` for a job in review and `retried` for a failed one, and is kept in the
351/// same statement.
352const 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";
379/// Make the row, or say this schedule is still in code: restore it if it was
380/// archived, and note the interval. `seen_at` moves at most once a minute, so a
381/// tick does not write each poll.
382const 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";
391/// Archive schedules no live process has in code. A row any process still ticks
392/// is seen within a minute, so one unseen for `stale_minutes` is in none, and an
393/// old replica in a rolling deploy keeps its schedules until it stops.
394const 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'))";
402/// The next due time after now, a whole number of intervals from the last, on the
403/// database's clock. One expression gives no drift, one catch-up, and no stacking.
404const 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
410/// The statements that make the queue's tables, in order, each safe to repeat. A
411/// service whose role may not change the schema runs these in a migration, and calls
412/// [`Queue::connect_existing`]. They need Postgres 14 or later.
413pub 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
430/// The longest `by` or `reason` kept on an act, in characters.
431pub const MAX_REASON_CHARS: usize = 200;
432
433/// The shortest gap between heartbeats, so a zero setting cannot spin.
434const MIN_HEARTBEAT: Duration = Duration::from_millis(20);
435
436/// The longest wait between attempts.
437const MAX_BACKOFF: Duration = Duration::from_hours(1);
438
439/// The longest error text kept on a row.
440const MAX_ERROR: usize = 1024;
441
442/// How long to wait before the attempt after `attempts`, doubling each time.
443#[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
451/// Cut `text` to at most [`MAX_ERROR`] bytes, on a character boundary.
452fn 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/// When a new job may first run.
472#[derive(Debug, Clone, Copy, PartialEq, Eq)]
473enum When {
474    Now,
475    At(DateTime<Utc>),
476    In(Duration),
477}
478
479/// A job to queue: its type, its arguments, and when and how it runs.
480#[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    /// A job for the handler named `name`, ready now, with the queue's limit on
504    /// tries and priority zero.
505    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    /// Stop a run of this job past `limit`, and count it as failed with the reason.
517    /// A job has no time limit unless it is given one.
518    #[must_use]
519    pub const fn timeout(mut self, limit: Duration) -> Self {
520        self.timeout = Some(limit);
521        self
522    }
523
524    /// Tries this job gets, including the first, or the queue's when `None`.
525    #[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    /// Run ahead of ready jobs with a lower priority. Zero by default, and may be
532    /// negative.
533    #[must_use]
534    pub const fn priority(mut self, priority: i16) -> Self {
535        self.priority = priority;
536        self
537    }
538
539    /// Run no sooner than `delay` from now, on the database's clock.
540    #[must_use]
541    pub const fn run_in(mut self, delay: Duration) -> Self {
542        self.when = When::In(delay);
543        self
544    }
545
546    /// Run no sooner than `at`.
547    #[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    /// The handler this job is for.
554    #[must_use]
555    pub fn name(&self) -> &str {
556        &self.name
557    }
558}
559
560/// Add `job` on `executor`, usually the caller's transaction, and return its id.
561/// The job then commits or rolls back with the transaction.
562///
563/// # Errors
564///
565/// Fails on a database error.
566pub 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
587/// Add every job in `jobs` on `connection`, returning their ids in order. On a
588/// transaction they commit or roll back together; [`Queue::enqueue_all`] opens one.
589///
590/// # Errors
591///
592/// Fails on a database error, after which a transaction must be rolled back.
593pub 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
601/// Refuse who or why for a person's act unless it is a code.
602fn 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
613/// Cancel the job `id` if it is still queued, as an act of `by`, for `reason`, kept in
614/// `axa_job_acts` in the caller's transaction, so that the caller's own audit row
615/// commits with it. Answers whether it was cancelled. A job already claimed runs on:
616/// cancelling cannot stop work in progress. A job in any other state, or no such job,
617/// changes nothing and writes no act.
618///
619/// The job's row stays locked until the transaction ends, so keep it short.
620///
621/// # Errors
622///
623/// Fails on a database error, and with [`Error::NotACode`] when `by` or `reason` is
624/// empty, longer than [`MAX_REASON_CHARS`], or has a line break, before anything is
625/// written.
626pub 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
635/// Queue the job `id` again, now, for one more try, if it failed or waits for review,
636/// as an act of `by`, for `reason`, kept in `axa_job_acts` in the caller's
637/// transaction. The act is [`ActKind::Retried`] for a failed job and
638/// [`ActKind::Released`] for one in review. Answers whether it was queued again. A job
639/// in any other state, or no such job, changes nothing and writes no act. Its last
640/// error stays until it next runs.
641///
642/// # Errors
643///
644/// Fails on a database error, and with [`Error::NotACode`] when `by` or `reason` is
645/// empty, longer than [`MAX_REASON_CHARS`], or has a line break, before anything is
646/// written.
647pub 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
656/// Run one act's statement, which changes the job and keeps the act together.
657async 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/// What a person did to a job.
677#[derive(Debug, Clone, Copy, PartialEq, Eq)]
678#[non_exhaustive]
679pub enum ActKind {
680    /// Cancelled it while it was queued.
681    Cancelled,
682    /// Queued it again after it failed.
683    Retried,
684    /// Released it from review, to run again.
685    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/// One act of a person on a job, as `axa_job_acts` keeps it. It outlives the job's
700/// row, which retention deletes.
701#[derive(Debug, Clone)]
702#[non_exhaustive]
703pub struct Act {
704    /// The act's row. It rises with each act.
705    pub id: i64,
706    /// The job it was done to.
707    pub job_id: Uuid,
708    /// The handler that job is for.
709    pub job_name: String,
710    /// What was done.
711    pub kind: ActKind,
712    /// The job's runs so far when it was done.
713    pub attempts: i32,
714    /// Who did it, as the service named them.
715    pub by: String,
716    /// Why, as a short code.
717    pub reason: String,
718    /// When, on the database's clock.
719    pub at: DateTime<Utc>,
720}
721
722/// Where a job is.
723#[derive(Debug, Clone, Copy, PartialEq, Eq)]
724pub enum JobState {
725    /// Waiting to be claimed, now or at its time.
726    Queued,
727    /// Claimed by a job loop.
728    Running,
729    /// Finished.
730    Completed,
731    /// Out of tries, or given up on.
732    Failed,
733    /// Cancelled before it ran.
734    Cancelled,
735    /// Its run lost its claim and was stopped, so it may have run in part or
736    /// twice. It is never retried by itself: a person looks, and calls
737    /// [`retry()`] to release it.
738    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/// What the queue knows of one job. Its arguments are left out.
756#[derive(Debug, Clone)]
757#[non_exhaustive]
758pub struct JobInfo {
759    /// The job's id.
760    pub id: Uuid,
761    /// The handler it is for.
762    pub name: String,
763    /// Where it is.
764    pub state: JobState,
765    /// Runs so far, including one in progress.
766    pub attempts: i32,
767    /// Its own limit on tries, or `None` for the queue's.
768    pub max_attempts: Option<i32>,
769    /// Its priority.
770    pub priority: i16,
771    /// Its time limit, if it was given one.
772    pub timeout: Option<Duration>,
773    /// The text of its last failure. For operators only: it is whatever the job's
774    /// error said.
775    pub error: Option<String>,
776    /// When it may run next, or last could.
777    pub run_at: DateTime<Utc>,
778    /// When its latest run started.
779    pub started_at: Option<DateTime<Utc>>,
780    /// When it was queued.
781    pub created_at: DateTime<Utc>,
782    /// When its row last changed.
783    pub updated_at: DateTime<Utc>,
784}
785
786/// How many jobs are in each state, for metrics.
787#[derive(Debug, Clone, Copy, Default, PartialEq)]
788#[non_exhaustive]
789pub struct Counts {
790    /// Queued and due.
791    pub ready: i64,
792    /// Queued for a later time.
793    pub waiting: i64,
794    /// Claimed and running.
795    pub running: i64,
796    /// Finished, and not yet deleted by retention.
797    pub completed: i64,
798    /// Failed, and not yet deleted by retention.
799    pub failed: i64,
800    /// Cancelled, and not yet deleted by retention.
801    pub cancelled: i64,
802    /// Stopped after losing their claim, waiting for a person. Never deleted by
803    /// retention.
804    pub needs_review: i64,
805    /// Seconds the oldest ready job has waited, or zero. A rising value means jobs
806    /// are not being run.
807    pub oldest_ready_seconds: f64,
808}
809
810/// A job queued every interval. See [Schedules](crate#schedules).
811#[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    /// Queue `job` every `interval`, under the schedule name `name`. The name keys
832    /// its row, so keep it stable across releases. A delay set on `job` is ignored.
833    ///
834    /// # Panics
835    ///
836    /// When the name is empty or over 200 characters, or the interval is under a
837    /// second, as Axum panics on a bad route: these are mistakes in code, found at
838    /// boot.
839    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    /// The first due time, used only when the schedule's row is made. With an
861    /// interval of 24 hours and a first time at 02:00 UTC, the job runs at 02:00
862    /// each day. Without one, the first is as soon as the row is made.
863    #[must_use]
864    pub const fn first_at(mut self, first: DateTime<Utc>) -> Self {
865        self.first = Some(first);
866        self
867    }
868
869    /// The schedule's name.
870    #[must_use]
871    pub fn name(&self) -> &str {
872        &self.name
873    }
874}
875
876/// What the table holds of one schedule.
877#[derive(Debug, Clone)]
878#[non_exhaustive]
879pub struct ScheduleInfo {
880    /// Its name.
881    pub name: String,
882    /// Its interval, as the code last had it.
883    pub interval: Option<Duration>,
884    /// When it is next due.
885    pub due_at: DateTime<Utc>,
886    /// The last job it queued.
887    pub last_job: Option<Uuid>,
888    /// That job's state, unless retention deleted it.
889    pub last_state: Option<JobState>,
890    /// That job's last failure text. For operators only.
891    pub last_error: Option<String>,
892    /// When a live process last had it in code, to within a minute.
893    pub seen_at: DateTime<Utc>,
894    /// When it was archived, as no live process had it in code. An archived
895    /// schedule queues nothing, and a schedule of that name in code restores it.
896    pub archived_at: Option<DateTime<Utc>>,
897    /// When its row was made.
898    pub created_at: DateTime<Utc>,
899}
900
901/// Queue tuning.
902#[derive(Debug, Clone)]
903pub struct QueueOptions {
904    /// Concurrent job loops, so the most jobs this process runs at once.
905    pub workers: usize,
906    /// Sleep between polls of an empty queue, and between schedule ticks.
907    pub poll_interval: Duration,
908    /// A running job not heard from for this many minutes is assumed lost with
909    /// its worker.
910    pub stale_minutes: i32,
911    /// How often a running job reports in. Keep it well under `stale_minutes`.
912    pub heartbeat_interval: Duration,
913    /// Tries a job gets, including the first, unless it has its own limit. One
914    /// means no retries.
915    pub max_attempts: i32,
916    /// The wait before the first retry.
917    pub retry_backoff: Duration,
918    /// Days to keep finished and cancelled rows.
919    pub retention_days: i32,
920    /// How often to recover stale jobs and delete old rows.
921    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
939/// A Postgres-backed job queue.
940pub 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
959/// A claimed job, as `run_one` holds it.
960struct Claimed {
961    id: Uuid,
962    name: String,
963    args: serde_json::Value,
964    attempts: i32,
965    limit: i32,
966    timeout: Option<Duration>,
967}
968
969/// How a run ended.
970enum Outcome {
971    /// The job answered, or failed, timed out, or panicked, with this text.
972    Done(Result<(), String>),
973    /// The run lost its claim and was stopped, for this reason.
974    Lost(String),
975}
976
977impl Queue {
978    /// A queue on `pool`, checking and changing nothing. Call [`Queue::check`] at
979    /// boot. Give the pool at least `workers + 2` connections.
980    #[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    /// Open a pool sized for the job loops, without touching the schema.
992    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    /// Connect to tables that a migration made, and change no schema, for a database
1002    /// role that may not. A migration runs [`SETUP_STATEMENTS`].
1003    ///
1004    /// # Errors
1005    ///
1006    /// Fails when the database cannot be reached, a table is missing or out of date,
1007    /// or a trigger of `axa_job_acts` is missing or not set to fire always, so a
1008    /// service finds out at boot rather than at its first job. There is no way to run
1009    /// without the triggers.
1010    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    /// Connect and create the tables if needed, which runs DDL at boot. Use
1017    /// [`Queue::connect_existing`] where a service's role may not.
1018    ///
1019    /// # Errors
1020    ///
1021    /// Fails when the database cannot be reached or the tables not created.
1022    pub async fn connect(uri: &str, options: QueueOptions) -> Result<Self> {
1023        let queue = Self::open(uri, options).await?;
1024        // `CREATE ... IF NOT EXISTS` is not safe against itself: two services
1025        // booting together can both pass the check and one then fails on a
1026        // catalog constraint. The lock makes setup run one at a time, and
1027        // ends with the transaction.
1028        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    /// Check that the tables exist and are up to date, and that the triggers of
1041    /// `axa_job_acts` are there and set to fire always.
1042    ///
1043    /// # Errors
1044    ///
1045    /// Fails when the database cannot be reached, a table is missing or out of date,
1046    /// or a trigger is missing or turned off.
1047    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    /// The pool, for a service's own queries or [`enqueue_on`] outside a
1079    /// transaction.
1080    #[must_use]
1081    pub const fn pool(&self) -> &PgPool {
1082        &self.pool
1083    }
1084
1085    /// The options this queue runs with.
1086    #[must_use]
1087    pub const fn options(&self) -> &QueueOptions {
1088        &self.options
1089    }
1090
1091    /// Run `handler` for jobs named `name`. Its error text is kept on the job's
1092    /// row. Registering a name again replaces its handler.
1093    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    /// Run at most `jobs` jobs named `name` at once in this process, and at least
1108    /// one. Other jobs keep the remaining loops busy.
1109    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    /// Queue a job on every due time of `schedule`.
1115    ///
1116    /// # Panics
1117    ///
1118    /// When a schedule of that name was added already.
1119    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    /// Add a job and return its id.
1129    ///
1130    /// # Errors
1131    ///
1132    /// Fails on a database error.
1133    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    /// Add a job with its own limit on tries, or the queue's when `None`.
1138    ///
1139    /// # Errors
1140    ///
1141    /// Fails on a database error.
1142    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    /// Add `job` and return its id.
1156    ///
1157    /// # Errors
1158    ///
1159    /// Fails on a database error.
1160    pub async fn enqueue_job(&self, job: &NewJob) -> Result<Uuid> {
1161        enqueue_on(&self.pool, job).await
1162    }
1163
1164    /// Add every job in `jobs` in one transaction, so all are queued or none.
1165    ///
1166    /// # Errors
1167    ///
1168    /// Fails on a database error, and then none was queued.
1169    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    /// What the queue knows of the job `id`, or `None` when there is no such job,
1177    /// as after retention deleted it.
1178    ///
1179    /// # Errors
1180    ///
1181    /// Fails on a database error.
1182    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    /// Every act a person did to the job `id`, oldest first, for a service's own
1209    /// review screens. Acts are kept after retention deletes the job's row. A person
1210    /// acts with [`cancel()`] and [`retry()`], in the caller's transaction.
1211    ///
1212    /// # Errors
1213    ///
1214    /// Fails on a database error.
1215    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    /// How many jobs are in each state.
1234    ///
1235    /// # Errors
1236    ///
1237    /// Fails on a database error.
1238    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    /// Check the database connection.
1253    ///
1254    /// # Errors
1255    ///
1256    /// Fails when the database does not answer.
1257    pub async fn ping(&self) -> Result<()> {
1258        sqlx::query("SELECT 1").execute(&self.pool).await?;
1259        Ok(())
1260    }
1261
1262    /// Recover jobs abandoned by a worker that died: queue again those with
1263    /// attempts left and fail the rest, returning how many were queued again.
1264    ///
1265    /// # Errors
1266    ///
1267    /// Fails on a database error.
1268    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    /// Delete finished and cancelled rows older than the retention period,
1283    /// returning how many went.
1284    ///
1285    /// # Errors
1286    ///
1287    /// Fails on a database error.
1288    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    /// Queue a job for each schedule that is due, returning how many were queued.
1297    /// [`Queue::run`] calls this on every poll. Any number of processes may call it
1298    /// at once: each due time is queued once.
1299    ///
1300    /// # Errors
1301    ///
1302    /// Fails on a database error, which leaves that schedule due for the next tick.
1303    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    /// One schedule's tick, returning whether it queued a job.
1314    async fn tick(&self, schedule: &Schedule) -> Result<bool> {
1315        // Outside the transaction: an upsert locks the row it meets, and replicas
1316        // would then wait on each other instead of skipping.
1317        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        // Another process holding the row is queuing this time already.
1325        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    /// Archive the schedules that no live process has in code, returning how many
1365    /// were archived. Rows are kept, for audit. Housekeeping runs this.
1366    ///
1367    /// # Errors
1368    ///
1369    /// Fails on a database error.
1370    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    /// Every schedule the table holds, archived or not, with its last job's state.
1381    ///
1382    /// # Errors
1383    ///
1384    /// Fails on a database error.
1385    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    /// One round of housekeeping. Failures are logged, because the next round
1410    /// can try again.
1411    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    /// Run `job` to its end in its own task, reporting in as it goes, so a panic
1426    /// stops only the job. The job is stopped when its time limit passes, when its
1427    /// claim is found taken, or when this future is dropped.
1428    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        // Not heard from for this long, a run is taken for lost by any worker.
1432        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    /// Mark a run's job for review, trying until the database answers, as the
1483    /// run must not be taken for a crash and retried.
1484    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    /// The job names this loop may claim now, holding a place under each limit
1504    /// that has one free. The places not used are given back when dropped.
1505    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    /// Claim and run one job, returning whether there was one. A job's own
1545    /// failure is recorded on its row, not returned.
1546    ///
1547    /// # Errors
1548    ///
1549    /// Fails on a database error.
1550    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        // Keep only the place this job takes.
1559        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            // It ran to its end, but its claim had been taken, so the job may be
1607            // running again elsewhere.
1608            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    /// Run job loops, housekeeping, and schedules until `shutdown` changes to
1619    /// `true`. Jobs in progress finish first, so a caller that must stop by a
1620    /// time limit drops this future at it, which aborts those jobs and leaves them
1621    /// for stale recovery. Housekeeping runs straight away, to recover anything a
1622    /// previous process left behind, and then on a timer.
1623    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    /// The names of the job types this queue handles.
1675    #[must_use]
1676    pub fn handled(&self) -> HashSet<&str> {
1677        self.handlers.keys().map(String::as_str).collect()
1678    }
1679}
1680
1681/// Run `round` now and then every `gap`, until `shutdown` is `true`.
1682async 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
1696/// A task that is aborted when its handle is dropped, so that dropping
1697/// [`Queue::run`] stops its loops and their jobs instead of leaving them running.
1698struct 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}