Skip to main content

acme_proxy_store/
job.rs

1//! The `jobs` model: the durable queue behind `jobs`.
2//!
3//! Every statement here is written so the *database* decides a race, never a
4//! read-then-write in Rust. Two shapes carry all of it:
5//!
6//! - **`INSERT … ON CONFLICT DO NOTHING` + `rows_affected() == 0`** for enqueue,
7//!   against the partial unique index on `(kind, dedup_key)`. No conflict
8//!   target is named, so the partial index catches it on either dialect, and
9//!   both report `0` rows. A `0` means a live job already
10//!   holds that identity — the same guard [`crate::upstream_order::UpstreamOrder::create`]
11//!   takes on its primary key.
12//! - **A guarded `UPDATE … RETURNING`** for the claim, and a guarded `UPDATE`
13//!   for every settlement, each carrying `AND status = 'running' AND lease_owner
14//!   = ?`. A runner whose lease expired and was reclaimed therefore cannot
15//!   overwrite the row a second runner now owns: its write affects zero rows and
16//!   says so.
17//!
18//! See `crates/store/migrations/20260815120000_add_jobs.sql` for why the table has no foreign
19//! key, why `kind` carries no `CHECK`, and why the identity index is partial.
20
21use crate::sql::Row;
22use serde_json::Value;
23use tracing::{debug, info, warn};
24use uuid::Uuid;
25
26use crate::db::Database;
27use crate::nonce::now_secs;
28use crate::status::JobStatus;
29
30/// One stored job row.
31///
32/// `status` and `kind` come back as the strings they were stored as, for the
33/// reason [`crate::audit::AuditEntry`] keeps `event` a `String`: an
34/// older binary meeting a row a newer one wrote should render it, not refuse to
35/// load. The runner never claims a `kind` its registry does not hold, so an
36/// unrecognised one is simply left alone.
37#[derive(Debug, Clone)]
38pub struct Job {
39    pub id: Uuid,
40    pub kind: String,
41    pub dedup_key: String,
42    pub payload: Value,
43    pub status: String,
44    pub run_at: i64,
45    pub attempts: i64,
46    pub max_attempts: i64,
47    pub deadline: Option<i64>,
48    pub lease_until: Option<i64>,
49    pub lease_owner: Option<String>,
50    pub last_error: Option<String>,
51    pub created_at: i64,
52    pub updated_at: i64,
53}
54
55/// Every column, in one place: the claim's `RETURNING` and the single-row read
56/// must select the same set or `from_row` fails on whichever forgot one.
57const COLUMNS: &str = "id, kind, dedup_key, payload, status, run_at, attempts, max_attempts, \
58                       deadline, lease_until, lease_owner, last_error, created_at, updated_at";
59
60/// The columns [`Job::enqueue`] writes.
61///
62/// A struct rather than eight positional parameters — which needed
63/// `#[allow(clippy::too_many_arguments)]`, and put four `&str`/`i64` values in
64/// a row where transposing two would still compile. `JobSpec` (the
65/// `crates/jobs/src/jobs/` half) is the caller-facing shape and this is the row it becomes;
66/// they are deliberately separate, so the storage layer names no `crates/jobs/src/jobs/`
67/// type.
68#[derive(Debug)]
69pub struct NewJob<'a> {
70    pub id: Uuid,
71    pub kind: &'a str,
72    pub dedup_key: &'a str,
73    pub payload: &'a Value,
74    pub run_at: i64,
75    pub deadline: Option<i64>,
76    /// Frozen onto the row here rather than read per attempt, so raising
77    /// `jobs.max_attempts` applies to new work and not to a waiting backlog.
78    pub max_attempts: i64,
79}
80
81/// The filters and page window [`Job::search`] applies.
82///
83/// `JobQuery { limit, offset, .. }` alone is "the newest page across every
84/// kind". `status` is a [`JobStatus`] because both front ends refuse an unknown
85/// `--status` / `?status=` by name before this layer is reached; `kind` stays a
86/// free `String` because a job kind is an open set (the migration puts no
87/// `CHECK` on the column on purpose), so a value matching nothing is an
88/// acceptable answer rather than one worth an error.
89///
90/// [`JobStatus`]: crate::status::JobStatus
91#[derive(Debug, Clone, Default)]
92pub struct JobQuery {
93    pub kind: Option<String>,
94    pub status: Option<crate::status::JobStatus>,
95    /// The caller clamps this (`admin.page_size_max` on the HTTP side, the CLI
96    /// window otherwise); this layer takes what it is given.
97    pub limit: i64,
98    pub offset: i64,
99}
100
101impl JobQuery {
102    /// Appends the `WHERE` clause shared by the page query and the count — one
103    /// function so a filter applied to only one cannot report a total the rows
104    /// disagree with. Every value goes through `push_bind`.
105    fn push_predicates(&self, builder: &mut crate::sql::Builder) {
106        crate::query::push_equalities(
107            builder,
108            crate::query::WHERE,
109            &[
110                ("kind = ", self.kind.as_deref()),
111                ("status = ", self.status.map(JobStatus::as_str)),
112            ],
113        );
114    }
115}
116
117impl Job {
118    fn from_row(row: Row) -> Result<Self, sqlx::Error> {
119        let payload_json: String = row.try_get("payload")?;
120        let payload: Value = serde_json::from_str(&payload_json)
121            .map_err(|error| sqlx::Error::Decode(Box::new(error)))?;
122
123        Ok(Self {
124            id: row.try_get("id")?,
125            kind: row.try_get("kind")?,
126            dedup_key: row.try_get("dedup_key")?,
127            payload,
128            status: row.try_get("status")?,
129            run_at: row.try_get("run_at")?,
130            attempts: row.try_get("attempts")?,
131            max_attempts: row.try_get("max_attempts")?,
132            deadline: row.try_get("deadline")?,
133            lease_until: row.try_get("lease_until")?,
134            lease_owner: row.try_get("lease_owner")?,
135            last_error: row.try_get("last_error")?,
136            created_at: row.try_get("created_at")?,
137            updated_at: row.try_get("updated_at")?,
138        })
139    }
140
141    /// Queues one job, unless a live one already holds `(kind, dedup_key)`.
142    ///
143    /// `Ok(false)` is "already queued", not an error: it is what two racing
144    /// callers must both be able to survive, and the partial unique index is
145    /// what decides which of them won. A job that already reached `done` or
146    /// `failed` releases the identity, so the same key can be queued again —
147    /// which is what makes a retried order and a periodic sweep both expressible
148    /// without a second table.
149    pub async fn enqueue(row: NewJob<'_>, database: &Database) -> Result<bool, sqlx::Error> {
150        Self::enqueue_on(row, database).await
151    }
152
153    /// [`enqueue`](Self::enqueue) on a connection of the caller's — inside a
154    /// transaction, so the row lands with the write that owes it or not at all.
155    pub async fn enqueue_on<'e>(
156        row: NewJob<'_>,
157        executor: impl Into<crate::sql::Exec<'e>>,
158    ) -> Result<bool, sqlx::Error> {
159        let now = now_secs();
160        let queued = crate::sql::query(
161            "INSERT INTO jobs \
162             (id, kind, dedup_key, payload, status, run_at, attempts, max_attempts, \
163              deadline, created_at, updated_at) \
164             VALUES (?, ?, ?, ?, 'ready', ?, 0, ?, ?, ?, ?) \
165             ON CONFLICT DO NOTHING;",
166        )
167        .bind(row.id)
168        .bind(row.kind)
169        .bind(row.dedup_key)
170        .bind(row.payload.to_string())
171        .bind(row.run_at)
172        .bind(row.max_attempts)
173        .bind(row.deadline)
174        .bind(now)
175        .bind(now)
176        .execute(executor)
177        .await?
178        .rows_affected()
179            == 1;
180
181        if queued {
182            debug!(
183                event = "db_job_enqueued",
184                outcome = "success",
185                job_id = %row.id,
186                job_kind = %row.kind,
187                dedup_key = %row.dedup_key,
188                run_at = row.run_at,
189            );
190        }
191        Ok(queued)
192    }
193
194    /// Claims the oldest eligible job of one of `kinds`, or `None`.
195    ///
196    /// One statement, and that is the point: the subselect picks a candidate and
197    /// the outer `AND status = 'ready'` is what makes the pick binding, so two
198    /// runners choosing the same row end with exactly one write. `None` collapses
199    /// "the queue was empty" and "somebody else won" — which is correct, because
200    /// the caller does the same thing either way.
201    ///
202    /// `attempts` increments here rather than at completion: a job that reliably
203    /// kills the process must still exhaust its budget, and nothing reports back
204    /// from a process that died.
205    ///
206    /// **`deadline` is deliberately not filtered here.** Skipping a dead row
207    /// would leave it `ready` and re-read on every tick for ever; the runner
208    /// claims it and retires it on the spot.
209    pub async fn claim_next(
210        runner_id: &str,
211        kinds: &[&str],
212        lease_until: i64,
213        now: i64,
214        database: &Database,
215    ) -> Result<Option<Self>, sqlx::Error> {
216        if kinds.is_empty() {
217            return Ok(None);
218        }
219
220        // sqlx has no array binding for SQLite, so the IN list is built from one
221        // placeholder per kind — never from the values themselves. Same shape as
222        // `UpstreamOrder::list_processing`, and `AssertSqlSafe` for the same
223        // reason: sqlx refuses a non-`'static` query string, and the only
224        // runtime part of this one is the count of `?`.
225        let placeholders = std::iter::repeat_n("?", kinds.len())
226            .collect::<Vec<_>>()
227            .join(", ");
228        let sql = format!(
229            "UPDATE jobs \
230             SET status = 'running', attempts = attempts + 1, lease_owner = ?, \
231                 lease_until = ?, updated_at = ? \
232             WHERE id = (SELECT id FROM jobs \
233                          WHERE status = 'ready' AND run_at <= ? AND kind IN ({placeholders}) \
234                          ORDER BY run_at ASC, created_at ASC LIMIT 1) \
235               AND status = 'ready' \
236             RETURNING {COLUMNS};"
237        );
238
239        let mut query = crate::sql::query(sqlx::AssertSqlSafe(sql))
240            .bind(runner_id)
241            .bind(lease_until)
242            .bind(now)
243            .bind(now);
244        for kind in kinds {
245            query = query.bind(*kind);
246        }
247        let row = query.fetch_optional(database).await?;
248        let job = row.map(Self::from_row).transpose()?;
249
250        if let Some(job) = &job {
251            debug!(
252                event = "db_job_claimed",
253                outcome = "success",
254                job_id = %job.id,
255                job_kind = %job.kind,
256                attempts = job.attempts,
257                lease_until = lease_until,
258            );
259        }
260        Ok(job)
261    }
262
263    /// Marks a claimed job finished. `Ok(false)` means the lease was lost.
264    pub async fn complete(
265        id: Uuid,
266        runner_id: &str,
267        database: &Database,
268    ) -> Result<bool, sqlx::Error> {
269        Self::settle(id, runner_id, "done", None, None, false, database).await
270    }
271
272    /// Returns a claimed job to the queue, to run again at `run_at`.
273    pub async fn retry(
274        id: Uuid,
275        runner_id: &str,
276        run_at: i64,
277        error: &str,
278        database: &Database,
279    ) -> Result<bool, sqlx::Error> {
280        Self::settle(
281            id,
282            runner_id,
283            "ready",
284            Some(run_at),
285            Some(error),
286            false,
287            database,
288        )
289        .await
290    }
291
292    /// Returns a claimed job to the queue as a *fresh* occurrence: the attempt
293    /// counter goes back to zero and the last error is cleared.
294    ///
295    /// That reset is what separates a periodic job from a retried one. A sweep
296    /// that runs every day for a year must not accumulate 365 attempts and
297    /// retire itself, and a successful occurrence must not leave the previous
298    /// failure's text sitting on the row as though it were current.
299    pub async fn reschedule(
300        id: Uuid,
301        runner_id: &str,
302        run_at: i64,
303        database: &Database,
304    ) -> Result<bool, sqlx::Error> {
305        Self::settle(id, runner_id, "ready", Some(run_at), None, true, database).await
306    }
307
308    /// Retires a claimed job permanently, recording why.
309    pub async fn abandon(
310        id: Uuid,
311        runner_id: &str,
312        error: &str,
313        database: &Database,
314    ) -> Result<bool, sqlx::Error> {
315        Self::settle(id, runner_id, "failed", None, Some(error), false, database).await
316    }
317
318    /// The one guarded write every settlement goes through.
319    ///
320    /// `AND status = 'running' AND lease_owner = ?` is the guard, and it is why
321    /// these four are one function: a settlement that forgot it would let a
322    /// runner whose lease had already been reclaimed overwrite the row a second
323    /// runner was working, and the four call sites would each have had to
324    /// remember. `rows_affected() == 1` is the caller's answer.
325    async fn settle(
326        id: Uuid,
327        runner_id: &str,
328        status: &str,
329        run_at: Option<i64>,
330        error: Option<&str>,
331        reset_attempts: bool,
332        database: &Database,
333    ) -> Result<bool, sqlx::Error> {
334        let now = now_secs();
335        let sql = format!(
336            "UPDATE jobs \
337             SET status = ?, last_error = ?, lease_owner = NULL, lease_until = NULL, \
338                 run_at = COALESCE(?, run_at), updated_at = ?{} \
339             WHERE id = ? AND status = 'running' AND lease_owner = ?;",
340            if reset_attempts { ", attempts = 0" } else { "" }
341        );
342        let settled = crate::sql::query(sqlx::AssertSqlSafe(sql))
343            .bind(status)
344            .bind(error)
345            .bind(run_at)
346            .bind(now)
347            .bind(id)
348            .bind(runner_id)
349            .execute(database)
350            .await?
351            .rows_affected()
352            == 1;
353        Ok(settled)
354    }
355
356    /// Returns to the queue every job whose runner died holding its lease.
357    ///
358    /// `attempts` is deliberately left alone: the attempt really was spent, and
359    /// a counter rewritten to look better would let a job that crashes the
360    /// process loop for ever.
361    pub async fn reclaim_expired(now: i64, database: &Database) -> Result<u64, sqlx::Error> {
362        let reclaimed = crate::sql::query(
363            "UPDATE jobs SET status = 'ready', lease_owner = NULL, lease_until = NULL, \
364             updated_at = ? WHERE status = 'running' AND lease_until <= ?;",
365        )
366        .bind(now)
367        .bind(now)
368        .execute(database)
369        .await?
370        .rows_affected();
371
372        if reclaimed > 0 {
373            warn!(
374                event = "db_job_leases_reclaimed",
375                outcome = "advisory",
376                rows_reclaimed = reclaimed,
377            );
378        }
379        Ok(reclaimed)
380    }
381
382    /// Releases every lease this runner holds, without settling the jobs.
383    ///
384    /// What a graceful shutdown runs, so a restart re-claims its own work
385    /// immediately instead of waiting out a full lease.
386    pub async fn release_owned(runner_id: &str, database: &Database) -> Result<u64, sqlx::Error> {
387        let released = crate::sql::query(
388            "UPDATE jobs SET status = 'ready', lease_owner = NULL, lease_until = NULL, \
389             updated_at = ? WHERE status = 'running' AND lease_owner = ?;",
390        )
391        .bind(now_secs())
392        .bind(runner_id)
393        .execute(database)
394        .await?
395        .rows_affected();
396
397        if released > 0 {
398            debug!(
399                event = "db_job_leases_released",
400                outcome = "success",
401                rows_released = released,
402            );
403        }
404        Ok(released)
405    }
406
407    /// One row by id.
408    pub async fn find_by_id(id: Uuid, database: &Database) -> Result<Option<Self>, sqlx::Error> {
409        // A `sql::Builder` rather than `sql::query`, which takes only a
410        // `&'static str` and so cannot be handed the shared `COLUMNS`. `id`
411        // still goes through `push_bind`, so nothing is interpolated.
412        let mut query = crate::sql::Builder::new(
413            database.dialect(),
414            format!("SELECT {COLUMNS} FROM jobs WHERE id = "),
415        );
416        query.push_bind(id);
417        let row = query.build().fetch_optional(database).await?;
418        row.map(Self::from_row).transpose()
419    }
420
421    /// The live job holding `(kind, dedup_key)`, if there is one.
422    pub async fn find_live(
423        kind: &str,
424        dedup_key: &str,
425        database: &Database,
426    ) -> Result<Option<Self>, sqlx::Error> {
427        let mut query = crate::sql::Builder::new(
428            database.dialect(),
429            format!(
430                "SELECT {COLUMNS} FROM jobs \
431             WHERE status IN ('ready', 'running') AND kind = "
432            ),
433        );
434        query.push_bind(kind);
435        query.push(" AND dedup_key = ");
436        query.push_bind(dedup_key);
437        let row = query.build().fetch_optional(database).await?;
438        row.map(Self::from_row).transpose()
439    }
440
441    /// How many live jobs of `kind` are queued or running.
442    ///
443    /// The kind-wide counterpart to [`Self::find_live`], for the callers that
444    /// want "is there work of this sort outstanding?" without naming a
445    /// `dedup_key` — a `notify_deliver` key is a per-occurrence uuid, so there
446    /// is no single key to ask about.
447    pub async fn count_live(kind: &str, database: &Database) -> Result<i64, sqlx::Error> {
448        let count: i64 = crate::sql::query(
449            "SELECT COUNT(*) FROM jobs WHERE status IN ('ready', 'running') AND kind = ?;",
450        )
451        .bind(kind)
452        .fetch_one(database)
453        .await?
454        .try_get(0usize)?;
455        Ok(count)
456    }
457
458    /// One page of jobs matching `query`, plus the total the same predicates
459    /// match unpaged. The only cross-kind listing this model offers, for the
460    /// operator surface (`acme-proxy jobs list`, `GET /api/jobs`).
461    ///
462    /// Built with a [`crate::sql::Builder`] rather than `sql::query`, which
463    /// takes only `&'static str` and so cannot be handed the shared `COLUMNS`
464    /// — the same reason [`Self::find_by_id`] does. Every value goes through
465    /// `push_bind`, so nothing operator-supplied is interpolated.
466    ///
467    /// **No index covers `WHERE kind = ? AND status = ? ORDER BY created_at`.**
468    /// This table stays small by construction: [`Self::cleanup`] deletes
469    /// terminal rows past `jobs.retention_days`, and the partial identity index
470    /// admits at most one live row per `(kind, dedup_key)`. A dedicated
471    /// `(kind, created_at)` index is a future migration if a deployment ever
472    /// says otherwise — the `Order::find_expiring` tradeoff exactly.
473    pub async fn search(
474        query: &JobQuery,
475        database: &Database,
476    ) -> Result<(Vec<Self>, i64), sqlx::Error> {
477        debug!(
478            event = "db_job_search_started",
479            outcome = "progress",
480            job_kind = ?query.kind,
481            status = ?query.status,
482            limit = query.limit,
483            offset = query.offset,
484        );
485
486        let mut page =
487            crate::sql::Builder::new(database.dialect(), format!("SELECT {COLUMNS} FROM jobs"));
488        query.push_predicates(&mut page);
489        // Newest first, `id` breaking the tie: `created_at` is whole seconds,
490        // and a v7 id sorts chronologically within one, so two jobs written in
491        // the same second cannot swap between pages.
492        page.push(" ORDER BY created_at DESC, id DESC LIMIT ");
493        page.push_bind(query.limit);
494        page.push(" OFFSET ");
495        page.push_bind(query.offset);
496
497        let rows = page.build().fetch_all(database).await?;
498        let jobs: Vec<Self> = rows
499            .into_iter()
500            .map(Self::from_row)
501            .collect::<Result<_, _>>()?;
502
503        let mut count = crate::sql::Builder::new(database.dialect(), "SELECT COUNT(*) FROM jobs");
504        query.push_predicates(&mut count);
505        let total: i64 = count.build().fetch_one(database).await?.try_get::<i64>(0)?;
506
507        Ok((jobs, total))
508    }
509
510    /// The most recent job for `(kind, dedup_key)` whatever its status.
511    ///
512    /// [`Self::find_live`]'s counterpart for a cross-link that must still
513    /// resolve after the job reached a terminal state — a relay job's
514    /// `dedup_key` is the local order id, and the operator surface links a
515    /// `done`/`failed` relay job to its `upstream_orders` row and back.
516    pub async fn find_latest_by_dedup(
517        kind: &str,
518        dedup_key: &str,
519        database: &Database,
520    ) -> Result<Option<Self>, sqlx::Error> {
521        let mut query = crate::sql::Builder::new(
522            database.dialect(),
523            format!("SELECT {COLUMNS} FROM jobs WHERE kind = "),
524        );
525        query.push_bind(kind);
526        query.push(" AND dedup_key = ");
527        query.push_bind(dedup_key);
528        query.push(" ORDER BY created_at DESC, id DESC LIMIT 1");
529        let row = query.build().fetch_optional(database).await?;
530        row.map(Self::from_row).transpose()
531    }
532
533    /// Retires a job at an operator's request, **from one named status**:
534    /// `from` → `cancelled`.
535    ///
536    /// `Ok(Some(job))` is the row as it now stands; `Ok(None)` means it was not
537    /// in `from` — the guard decided, not a read-then-write. `running` is never
538    /// a legal `from`: a runner owns such a row, and its lease will expire or it
539    /// will settle.
540    ///
541    /// **The status is a parameter rather than the `IN ('ready','failed')` this
542    /// used to guard on**, and that is what lets the caller act on *which* of
543    /// the two it was. `RETURNING` hands back the row after the write, so a
544    /// single statement over both cannot say which state it came from — and the
545    /// difference matters exactly once: a `ready` relay job is in flight and
546    /// its order still has to be abandoned, while a `failed` one was already
547    /// abandoned by `runner::retire`, so repeating that would write a second
548    /// `certificate_issue_failed` row and overwrite `upstream_orders.error`
549    /// with a cancellation message, destroying the upstream's own diagnosis.
550    /// See `admin::ops::cancel_job`.
551    pub async fn cancel_row(
552        id: Uuid,
553        from: JobStatus,
554        database: &Database,
555    ) -> Result<Option<Self>, sqlx::Error> {
556        debug_assert_ne!(
557            from,
558            JobStatus::Running,
559            "a running job is the runner's; cancelling it would strand a lease"
560        );
561        let sql = format!(
562            "UPDATE jobs \
563             SET status = 'cancelled', updated_at = ?, lease_owner = NULL, lease_until = NULL \
564             WHERE id = ? AND status = ? \
565             RETURNING {COLUMNS};"
566        );
567        let row = crate::sql::query(sqlx::AssertSqlSafe(sql))
568            .bind(now_secs())
569            .bind(id)
570            .bind(from.as_str())
571            .fetch_optional(database)
572            .await?;
573        let job = row.map(Self::from_row).transpose()?;
574        if job.is_some() {
575            info!(event = "db_job_cancelled", outcome = "success", job_id = %id, from = from.as_str());
576        }
577        Ok(job)
578    }
579
580    /// Pulls a live `ready` job's `run_at` forward to now. `Ok(None)` if it is
581    /// not `ready`. The runner picks the change up within `jobs.poll_interval_ms`
582    /// — this does not wake it.
583    pub async fn advance_row(id: Uuid, database: &Database) -> Result<Option<Self>, sqlx::Error> {
584        let now = now_secs();
585        let sql = format!(
586            "UPDATE jobs SET run_at = ?, updated_at = ? \
587             WHERE id = ? AND status = 'ready' RETURNING {COLUMNS};"
588        );
589        let row = crate::sql::query(sqlx::AssertSqlSafe(sql))
590            .bind(now)
591            .bind(now)
592            .bind(id)
593            .fetch_optional(database)
594            .await?;
595        let job = row.map(Self::from_row).transpose()?;
596        if job.is_some() {
597            info!(event = "db_job_advanced", outcome = "success", job_id = %id);
598        }
599        Ok(job)
600    }
601
602    /// Revives a permanently-failed job for exactly one more attempt: `failed`
603    /// → `ready`, `run_at` now, `attempts` set to `max_attempts - 1`.
604    /// `Ok(None)` if the row is not `failed`.
605    ///
606    /// `max_attempts - 1` rather than `0` is the whole point: the operator is
607    /// asking for one retry, not a fresh budget — if that attempt also fails
608    /// the job is `failed` again and stays there without another nudge.
609    /// `last_error` is left in place as the record of why it stopped, until the
610    /// next claim overwrites it. The `CASE` guards a `max_attempts = 0` row
611    /// (the column carries no `CHECK`) — spelled out rather than `MAX(x, 0)`,
612    /// which is SQLite's *scalar* two-argument max and has no PostgreSQL
613    /// counterpart: there `max()` is an aggregate and the scalar is `GREATEST`,
614    /// so the one spelling both accept is this one. Keeping it portable is
615    /// worth more than the brevity, since the alternative is a fourth
616    /// `Dialect` fork for one expression.
617    pub async fn revive_row(id: Uuid, database: &Database) -> Result<Option<Self>, sqlx::Error> {
618        let now = now_secs();
619        let sql = format!(
620            "UPDATE jobs \
621             SET status = 'ready', run_at = ?, updated_at = ?, \
622                 attempts = CASE WHEN max_attempts - 1 > 0 \
623                                 THEN max_attempts - 1 ELSE 0 END, \
624                 lease_owner = NULL, lease_until = NULL \
625             WHERE id = ? AND status = 'failed' RETURNING {COLUMNS};"
626        );
627        let row = crate::sql::query(sqlx::AssertSqlSafe(sql))
628            .bind(now)
629            .bind(now)
630            .bind(id)
631            .fetch_optional(database)
632            .await?;
633        let job = row.map(Self::from_row).transpose()?;
634        if let Some(job) = &job {
635            info!(
636                event = "db_job_revived",
637                outcome = "success",
638                job_id = %id,
639                attempts = job.attempts,
640            );
641        }
642        Ok(job)
643    }
644
645    /// Deletes terminal rows settled before `cutoff`, returning how many went.
646    ///
647    /// Only terminal ones: a `ready` job scheduled far in the future is not old,
648    /// however long ago it was written, and a `running` one is somebody's work.
649    pub async fn cleanup(cutoff: i64, database: &Database) -> Result<u64, sqlx::Error> {
650        let deleted = crate::sql::query(
651            "DELETE FROM jobs \
652             WHERE status IN ('done', 'failed', 'cancelled') AND updated_at < ?;",
653        )
654        .bind(cutoff)
655        .execute(database)
656        .await?
657        .rows_affected();
658        info!(
659            event = "db_job_cleanup_completed",
660            outcome = "success",
661            rows_removed = deleted,
662            cutoff = cutoff,
663        );
664        Ok(deleted)
665    }
666}
667
668#[cfg(test)]
669mod tests {
670    use super::*;
671    use serde_json::json;
672    use std::sync::Arc;
673
674    async fn db() -> Arc<Database> {
675        Arc::new(Database::connect_for_test().await.unwrap())
676    }
677
678    /// A stable id for a fixture, derived from a readable name.
679    ///
680    /// Ids are UUIDs, so a fixture cannot spell one inline and stay legible --
681    /// and minting one per row would move the name out of the assertion, which
682    /// is where it does the explaining. The bytes are the name itself, padded:
683    /// distinct names give distinct ids, which is the whole of what a fixture
684    /// needs from them.
685    fn job_id(name: &str) -> Uuid {
686        let mut bytes = [0u8; 16];
687        let name = name.as_bytes();
688        let take = name.len().min(16);
689        bytes[..take].copy_from_slice(&name[..take]);
690        Uuid::from_bytes(bytes)
691    }
692
693    /// Queues one job with sensible defaults, returning whether it was queued.
694    async fn enqueue(id: Uuid, key: &str, run_at: i64, database: &Database) -> bool {
695        Job::enqueue(
696            NewJob {
697                id,
698                kind: "test",
699                dedup_key: key,
700                payload: &json!({"n": 1}),
701                run_at,
702                deadline: None,
703                max_attempts: 3,
704            },
705            database,
706        )
707        .await
708        .unwrap()
709    }
710
711    async fn claim(runner: &str, database: &Database) -> Option<Job> {
712        Job::claim_next(runner, &["test"], now_secs() + 60, now_secs(), database)
713            .await
714            .unwrap()
715    }
716
717    #[tokio::test]
718    async fn a_queued_job_round_trips_through_every_column() {
719        let database = db().await;
720        let deadline = now_secs() + 900;
721        assert!(
722            Job::enqueue(
723                NewJob {
724                    id: job_id("job-1"),
725                    kind: "relay",
726                    dedup_key: "ord-1",
727                    payload: &json!({"order_id": "ord-1"}),
728                    run_at: 1_234,
729                    deadline: Some(deadline),
730                    max_attempts: 7,
731                },
732                &database,
733            )
734            .await
735            .unwrap()
736        );
737
738        let job = Job::find_by_id(job_id("job-1"), &database)
739            .await
740            .unwrap()
741            .unwrap();
742        assert_eq!(job.kind, "relay");
743        assert_eq!(job.dedup_key, "ord-1");
744        assert_eq!(job.payload, json!({"order_id": "ord-1"}));
745        assert_eq!(job.status, "ready");
746        assert_eq!(job.run_at, 1_234);
747        assert_eq!(job.attempts, 0);
748        assert_eq!(job.max_attempts, 7);
749        assert_eq!(job.deadline, Some(deadline));
750        assert!(job.lease_until.is_none());
751        assert!(job.lease_owner.is_none());
752        assert!(job.last_error.is_none());
753    }
754
755    /// The partial index's whole point: an identity is held by a *live* job and
756    /// released the moment one settles.
757    #[tokio::test]
758    async fn a_live_job_holds_its_identity_and_a_settled_one_releases_it() {
759        let database = db().await;
760        assert!(enqueue(job_id("job-1"), "ord-1", now_secs(), &database).await);
761        assert!(
762            !enqueue(job_id("job-2"), "ord-1", now_secs(), &database).await,
763            "a second live job must not take an identity already held"
764        );
765
766        // Claimed is still live.
767        let job = claim("runner-a", &database).await.unwrap();
768        assert!(
769            !enqueue(job_id("job-3"), "ord-1", now_secs(), &database).await,
770            "'running' holds the identity exactly as 'ready' does"
771        );
772
773        Job::complete(job.id, "runner-a", &database).await.unwrap();
774        assert!(
775            enqueue(job_id("job-4"), "ord-1", now_secs(), &database).await,
776            "a settled job releases its identity"
777        );
778    }
779
780    #[tokio::test]
781    async fn claiming_takes_the_oldest_eligible_row_and_only_once() {
782        let database = db().await;
783        assert!(enqueue(job_id("job-new"), "b", now_secs() - 10, &database).await);
784        assert!(enqueue(job_id("job-old"), "a", now_secs() - 100, &database).await);
785
786        let first = claim("runner-a", &database).await.unwrap();
787        assert_eq!(first.id, job_id("job-old"), "oldest `run_at` first");
788        assert_eq!(first.attempts, 1, "the counter moves at claim");
789        assert_eq!(first.lease_owner.as_deref(), Some("runner-a"));
790
791        let second = claim("runner-b", &database).await.unwrap();
792        assert_eq!(second.id, job_id("job-new"));
793        assert!(
794            claim("runner-c", &database).await.is_none(),
795            "a claimed row is not claimable again"
796        );
797    }
798
799    #[tokio::test]
800    async fn claiming_skips_a_job_whose_run_at_has_not_arrived() {
801        let database = db().await;
802        assert!(enqueue(job_id("job-1"), "a", now_secs() + 3_600, &database).await);
803        assert!(claim("runner-a", &database).await.is_none());
804    }
805
806    #[tokio::test]
807    async fn claiming_skips_a_kind_the_runner_does_not_hold() {
808        let database = db().await;
809        assert!(enqueue(job_id("job-1"), "a", now_secs(), &database).await);
810
811        let claimed = Job::claim_next(
812            "runner-a",
813            &["something-else"],
814            now_secs() + 60,
815            now_secs(),
816            &database,
817        )
818        .await
819        .unwrap();
820        assert!(
821            claimed.is_none(),
822            "an unregistered kind is left alone, not mis-run"
823        );
824    }
825
826    #[tokio::test]
827    async fn claiming_with_no_registered_kinds_asks_the_database_nothing() {
828        let database = db().await;
829        assert!(enqueue(job_id("job-1"), "a", now_secs(), &database).await);
830        assert!(
831            Job::claim_next("runner-a", &[], now_secs() + 60, now_secs(), &database)
832                .await
833                .unwrap()
834                .is_none()
835        );
836    }
837
838    /// The guard that makes a reclaimed lease safe: the runner that lost it
839    /// writes nothing, rather than overwriting the row somebody else now owns.
840    #[tokio::test]
841    async fn a_settlement_from_a_runner_that_lost_the_lease_writes_nothing() {
842        let database = db().await;
843        assert!(enqueue(job_id("job-1"), "a", now_secs(), &database).await);
844        let job = claim("runner-a", &database).await.unwrap();
845
846        for settled in [
847            Job::complete(job.id, "runner-b", &database).await.unwrap(),
848            Job::retry(job.id, "runner-b", now_secs(), "x", &database)
849                .await
850                .unwrap(),
851            Job::reschedule(job.id, "runner-b", now_secs(), &database)
852                .await
853                .unwrap(),
854            Job::abandon(job.id, "runner-b", "x", &database)
855                .await
856                .unwrap(),
857        ] {
858            assert!(!settled, "the lease guard must refuse a foreign settlement");
859        }
860
861        let after = Job::find_by_id(job_id("job-1"), &database)
862            .await
863            .unwrap()
864            .unwrap();
865        assert_eq!(after.status, "running");
866        assert_eq!(after.lease_owner.as_deref(), Some("runner-a"));
867    }
868
869    #[tokio::test]
870    async fn retry_returns_the_job_at_its_new_time_and_keeps_the_attempt_count() {
871        let database = db().await;
872        assert!(enqueue(job_id("job-1"), "a", now_secs(), &database).await);
873        let job = claim("runner-a", &database).await.unwrap();
874
875        assert!(
876            Job::retry(job.id, "runner-a", 9_999, "upstream timed out", &database)
877                .await
878                .unwrap()
879        );
880
881        let after = Job::find_by_id(job_id("job-1"), &database)
882            .await
883            .unwrap()
884            .unwrap();
885        assert_eq!(after.status, "ready");
886        assert_eq!(after.run_at, 9_999);
887        assert_eq!(after.attempts, 1, "a retry does not forgive the attempt");
888        assert_eq!(after.last_error.as_deref(), Some("upstream timed out"));
889        assert!(after.lease_owner.is_none());
890    }
891
892    #[tokio::test]
893    async fn reschedule_resets_the_attempt_count_and_clears_the_last_error() {
894        let database = db().await;
895        assert!(enqueue(job_id("job-1"), "a", now_secs(), &database).await);
896        let job = claim("runner-a", &database).await.unwrap();
897        Job::retry(job.id, "runner-a", now_secs(), "a failure", &database)
898            .await
899            .unwrap();
900        let job = claim("runner-a", &database).await.unwrap();
901        assert_eq!(job.attempts, 2);
902
903        assert!(
904            Job::reschedule(job.id, "runner-a", 5_000, &database)
905                .await
906                .unwrap()
907        );
908
909        let after = Job::find_by_id(job_id("job-1"), &database)
910            .await
911            .unwrap()
912            .unwrap();
913        assert_eq!(after.status, "ready");
914        assert_eq!(after.run_at, 5_000);
915        assert_eq!(after.attempts, 0, "a fresh occurrence starts fresh");
916        assert!(after.last_error.is_none());
917    }
918
919    #[tokio::test]
920    async fn abandon_is_terminal_and_records_why() {
921        let database = db().await;
922        assert!(enqueue(job_id("job-1"), "a", now_secs(), &database).await);
923        let job = claim("runner-a", &database).await.unwrap();
924
925        assert!(
926            Job::abandon(job.id, "runner-a", "the order no longer exists", &database)
927                .await
928                .unwrap()
929        );
930
931        let after = Job::find_by_id(job_id("job-1"), &database)
932            .await
933            .unwrap()
934            .unwrap();
935        assert_eq!(after.status, "failed");
936        assert_eq!(
937            after.last_error.as_deref(),
938            Some("the order no longer exists")
939        );
940    }
941
942    #[tokio::test]
943    async fn reclaim_takes_only_leases_that_have_expired() {
944        let database = db().await;
945        assert!(enqueue(job_id("job-live"), "a", now_secs(), &database).await);
946        assert!(enqueue(job_id("job-dead"), "b", now_secs(), &database).await);
947
948        // One long lease, one already expired.
949        Job::claim_next(
950            "runner-a",
951            &["test"],
952            now_secs() + 600,
953            now_secs(),
954            &database,
955        )
956        .await
957        .unwrap();
958        Job::claim_next("runner-a", &["test"], now_secs() - 1, now_secs(), &database)
959            .await
960            .unwrap();
961
962        assert_eq!(
963            Job::reclaim_expired(now_secs(), &database).await.unwrap(),
964            1
965        );
966
967        let reclaimed = Job::find_by_id(job_id("job-dead"), &database)
968            .await
969            .unwrap()
970            .unwrap();
971        assert_eq!(reclaimed.status, "ready");
972        assert!(reclaimed.lease_owner.is_none());
973        assert_eq!(
974            reclaimed.attempts, 1,
975            "the attempt was spent, and the row must keep saying so"
976        );
977
978        let held = Job::find_by_id(job_id("job-live"), &database)
979            .await
980            .unwrap()
981            .unwrap();
982        assert_eq!(held.status, "running");
983    }
984
985    #[tokio::test]
986    async fn release_owned_is_scoped_to_one_runner() {
987        let database = db().await;
988        assert!(enqueue(job_id("job-1"), "a", now_secs(), &database).await);
989        assert!(enqueue(job_id("job-2"), "b", now_secs(), &database).await);
990        claim("runner-a", &database).await.unwrap();
991        claim("runner-b", &database).await.unwrap();
992
993        assert_eq!(Job::release_owned("runner-a", &database).await.unwrap(), 1);
994        assert_eq!(
995            Job::find_by_id(job_id("job-1"), &database)
996                .await
997                .unwrap()
998                .unwrap()
999                .status,
1000            "ready"
1001        );
1002        assert_eq!(
1003            Job::find_by_id(job_id("job-2"), &database)
1004                .await
1005                .unwrap()
1006                .unwrap()
1007                .status,
1008            "running"
1009        );
1010    }
1011
1012    #[tokio::test]
1013    async fn find_live_sees_a_queued_job_and_not_a_settled_one() {
1014        let database = db().await;
1015        assert!(enqueue(job_id("job-1"), "ord-1", now_secs(), &database).await);
1016        assert!(
1017            Job::find_live("test", "ord-1", &database)
1018                .await
1019                .unwrap()
1020                .is_some()
1021        );
1022
1023        let job = claim("runner-a", &database).await.unwrap();
1024        Job::complete(job.id, "runner-a", &database).await.unwrap();
1025        assert!(
1026            Job::find_live("test", "ord-1", &database)
1027                .await
1028                .unwrap()
1029                .is_none()
1030        );
1031    }
1032
1033    #[tokio::test]
1034    async fn cleanup_removes_settled_rows_strictly_older_than_the_cutoff() {
1035        let database = db().await;
1036        assert!(enqueue(job_id("job-done"), "a", now_secs(), &database).await);
1037        assert!(enqueue(job_id("job-live"), "b", now_secs() + 3_600, &database).await);
1038        let job = claim("runner-a", &database).await.unwrap();
1039        Job::complete(job.id, "runner-a", &database).await.unwrap();
1040
1041        // The cutoff is `updated_at`, which `complete` has just stamped. Read it
1042        // back off the row rather than asking the clock again: a second boundary
1043        // falling between the two makes `now_secs()` one past the stamp, and the
1044        // row this line asserts is kept would be deleted.
1045        let settled = Job::find_by_id(job_id("job-done"), &database)
1046            .await
1047            .unwrap()
1048            .unwrap();
1049        assert_eq!(
1050            Job::cleanup(settled.updated_at, &database).await.unwrap(),
1051            0
1052        );
1053        assert_eq!(
1054            Job::cleanup(settled.updated_at + 1, &database)
1055                .await
1056                .unwrap(),
1057            1
1058        );
1059        assert!(
1060            Job::find_by_id(job_id("job-done"), &database)
1061                .await
1062                .unwrap()
1063                .is_none()
1064        );
1065        assert!(
1066            Job::find_by_id(job_id("job-live"), &database)
1067                .await
1068                .unwrap()
1069                .is_some(),
1070            "a job still queued is not old, however long ago it was written"
1071        );
1072    }
1073
1074    #[tokio::test]
1075    async fn an_unknown_status_is_refused_by_the_check_constraint() {
1076        let database = db().await;
1077        let error = crate::sql::query(
1078            "INSERT INTO jobs (id, kind, dedup_key, payload, status, run_at, attempts, \
1079             max_attempts, created_at, updated_at) \
1080             VALUES (?, 'test', 'k', '{}', 'halfway', 0, 0, 1, 0, 0);",
1081        )
1082        .bind(crate::id::mint())
1083        .execute(&database)
1084        .await
1085        .unwrap_err();
1086        assert!(crate::sql::is_check_violation(&error), "{error}");
1087    }
1088
1089    // --- the operator surface: `JobQuery`/`search` and the guarded mutations --
1090
1091    use crate::status::JobStatus;
1092
1093    /// Queues one job of a named kind with `max_attempts` and `run_at` chosen.
1094    async fn enqueue_kind(
1095        id: Uuid,
1096        kind: &str,
1097        key: &str,
1098        run_at: i64,
1099        max_attempts: i64,
1100        database: &Database,
1101    ) -> bool {
1102        Job::enqueue(
1103            NewJob {
1104                id,
1105                kind,
1106                dedup_key: key,
1107                payload: &json!({}),
1108                run_at,
1109                deadline: None,
1110                max_attempts,
1111            },
1112            database,
1113        )
1114        .await
1115        .unwrap()
1116    }
1117
1118    /// One job at `failed` with `max_attempts`, `attempts = 1` and a
1119    /// `last_error` — the state the runner leaves after a single failed
1120    /// attempt. A direct `UPDATE` rather than claim+abandon because a test
1121    /// seeding several jobs of one kind cannot say *which* one `claim_next`
1122    /// takes (it takes the oldest); `abandon`'s own path is covered by
1123    /// `abandon_is_terminal_and_records_why`.
1124    async fn failed_job_max(
1125        id: Uuid,
1126        kind: &str,
1127        key: &str,
1128        max_attempts: i64,
1129        database: &Database,
1130    ) -> Job {
1131        assert!(enqueue_kind(id, kind, key, now_secs(), max_attempts, database).await);
1132        crate::sql::query(
1133            "UPDATE jobs SET status = 'failed', attempts = 1, \
1134             last_error = 'upstream said no', updated_at = ? WHERE id = ?;",
1135        )
1136        .bind(now_secs())
1137        .bind(id)
1138        .execute(database)
1139        .await
1140        .unwrap();
1141        Job::find_by_id(id, database).await.unwrap().unwrap()
1142    }
1143
1144    async fn failed_job(id: Uuid, kind: &str, key: &str, database: &Database) -> Job {
1145        failed_job_max(id, kind, key, 3, database).await
1146    }
1147
1148    #[tokio::test]
1149    async fn search_filters_kind_and_status_and_pages_without_overlap() {
1150        let database = db().await;
1151        // Three `relay` jobs, two `sweep` jobs; drive one relay job to `failed`.
1152        for i in 0..3 {
1153            assert!(
1154                enqueue_kind(
1155                    job_id(&format!("relay-{i}")),
1156                    "relay",
1157                    &format!("ord-{i}"),
1158                    now_secs() - i64::from(10 - i),
1159                    3,
1160                    &database,
1161                )
1162                .await
1163            );
1164        }
1165        for i in 0..2 {
1166            assert!(
1167                enqueue_kind(
1168                    job_id(&format!("sweep-{i}")),
1169                    "sweep",
1170                    &format!("s-{i}"),
1171                    now_secs(),
1172                    3,
1173                    &database,
1174                )
1175                .await
1176            );
1177        }
1178        failed_job(job_id("relay-failed"), "relay", "ord-f", &database).await;
1179
1180        // Kind narrows page and total together.
1181        let (rows, total) = Job::search(
1182            &JobQuery {
1183                kind: Some("relay".to_string()),
1184                limit: 50,
1185                ..JobQuery::default()
1186            },
1187            &database,
1188        )
1189        .await
1190        .unwrap();
1191        assert_eq!(total, 4);
1192        assert_eq!(rows.len(), 4);
1193
1194        // Kind + status together.
1195        let (rows, total) = Job::search(
1196            &JobQuery {
1197                kind: Some("relay".to_string()),
1198                status: Some(JobStatus::Failed),
1199                limit: 50,
1200                offset: 0,
1201            },
1202            &database,
1203        )
1204        .await
1205        .unwrap();
1206        assert_eq!(total, 1);
1207        assert_eq!(rows[0].id, job_id("relay-failed"));
1208
1209        // Three single-row pages over the four `relay` jobs see each once, and
1210        // the total stays unpaged on every page.
1211        let mut seen = std::collections::BTreeSet::new();
1212        for offset in 0..4 {
1213            let (rows, total) = Job::search(
1214                &JobQuery {
1215                    kind: Some("relay".to_string()),
1216                    limit: 1,
1217                    offset,
1218                    ..JobQuery::default()
1219                },
1220                &database,
1221            )
1222            .await
1223            .unwrap();
1224            assert_eq!(total, 4);
1225            assert_eq!(rows.len(), 1);
1226            assert!(seen.insert(rows[0].id), "a row appeared on two pages");
1227        }
1228        assert_eq!(seen.len(), 4);
1229    }
1230
1231    #[tokio::test]
1232    async fn a_kind_filter_value_is_bound_not_interpolated() {
1233        let database = db().await;
1234        assert!(enqueue_kind(job_id("job-1"), "relay", "k", now_secs(), 3, &database).await);
1235        let (rows, total) = Job::search(
1236            &JobQuery {
1237                kind: Some("' OR 1=1 --".to_string()),
1238                limit: 50,
1239                ..JobQuery::default()
1240            },
1241            &database,
1242        )
1243        .await
1244        .unwrap();
1245        assert_eq!(total, 0);
1246        assert!(rows.is_empty());
1247        // The table is still there.
1248        assert!(
1249            Job::find_by_id(job_id("job-1"), &database)
1250                .await
1251                .unwrap()
1252                .is_some()
1253        );
1254    }
1255
1256    #[tokio::test]
1257    async fn cancel_row_moves_ready_and_failed_and_refuses_the_rest() {
1258        let database = db().await;
1259
1260        // ready -> cancelled.
1261        assert!(enqueue_kind(job_id("ready"), "test", "a", now_secs(), 3, &database).await);
1262        let cancelled = Job::cancel_row(job_id("ready"), JobStatus::Ready, &database)
1263            .await
1264            .unwrap()
1265            .expect("a ready job cancels");
1266        assert_eq!(cancelled.status, "cancelled");
1267        // A second cancel finds nothing to do.
1268        assert!(
1269            Job::cancel_row(job_id("ready"), JobStatus::Ready, &database)
1270                .await
1271                .unwrap()
1272                .is_none()
1273        );
1274
1275        // failed -> cancelled.
1276        failed_job(job_id("failed"), "test", "b", &database).await;
1277        assert!(
1278            Job::cancel_row(job_id("failed"), JobStatus::Failed, &database)
1279                .await
1280                .unwrap()
1281                .is_some()
1282        );
1283
1284        // running is refused, row untouched.
1285        assert!(enqueue_kind(job_id("running"), "test", "c", now_secs(), 3, &database).await);
1286        Job::claim_next("r", &["test"], now_secs() + 60, now_secs(), &database)
1287            .await
1288            .unwrap();
1289        assert!(
1290            Job::cancel_row(job_id("running"), JobStatus::Ready, &database)
1291                .await
1292                .unwrap()
1293                .is_none()
1294        );
1295        assert_eq!(
1296            Job::find_by_id(job_id("running"), &database)
1297                .await
1298                .unwrap()
1299                .unwrap()
1300                .status,
1301            "running"
1302        );
1303
1304        // done is refused.
1305        assert!(enqueue_kind(job_id("done"), "test", "d", now_secs(), 3, &database).await);
1306        let job = Job::claim_next("r", &["test"], now_secs() + 60, now_secs(), &database)
1307            .await
1308            .unwrap()
1309            .unwrap();
1310        Job::complete(job.id, "r", &database).await.unwrap();
1311        assert!(
1312            Job::cancel_row(job_id("done"), JobStatus::Ready, &database)
1313                .await
1314                .unwrap()
1315                .is_none()
1316        );
1317    }
1318
1319    #[tokio::test]
1320    async fn advance_row_nudges_a_ready_job_and_refuses_a_running_one() {
1321        let database = db().await;
1322        assert!(
1323            enqueue_kind(
1324                job_id("ready"),
1325                "test",
1326                "a",
1327                now_secs() + 3_600,
1328                3,
1329                &database
1330            )
1331            .await
1332        );
1333        let advanced = Job::advance_row(job_id("ready"), &database)
1334            .await
1335            .unwrap()
1336            .expect("a ready job advances");
1337        assert!(advanced.run_at <= now_secs() + 1);
1338        assert_eq!(advanced.attempts, 0, "advancing does not spend an attempt");
1339
1340        // running / failed / done all refuse.
1341        Job::claim_next("r", &["test"], now_secs() + 60, now_secs(), &database)
1342            .await
1343            .unwrap();
1344        assert!(
1345            Job::advance_row(job_id("ready"), &database)
1346                .await
1347                .unwrap()
1348                .is_none()
1349        );
1350
1351        failed_job(job_id("failed"), "test", "b", &database).await;
1352        assert!(
1353            Job::advance_row(job_id("failed"), &database)
1354                .await
1355                .unwrap()
1356                .is_none()
1357        );
1358    }
1359
1360    #[tokio::test]
1361    async fn revive_row_sets_attempts_to_max_minus_one_and_refuses_a_live_job() {
1362        let database = db().await;
1363
1364        let failed = failed_job(job_id("failed"), "test", "a", &database).await;
1365        assert_eq!(failed.max_attempts, 3);
1366        let revived = Job::revive_row(job_id("failed"), &database)
1367            .await
1368            .unwrap()
1369            .expect("a failed job revives");
1370        assert_eq!(revived.status, "ready");
1371        assert_eq!(revived.attempts, 2, "exactly one attempt left");
1372        assert!(revived.run_at <= now_secs() + 1);
1373        assert_eq!(
1374            revived.last_error.as_deref(),
1375            Some("upstream said no"),
1376            "the last error stays as the record of why it stopped"
1377        );
1378
1379        // A live `ready` job is refused.
1380        assert!(enqueue_kind(job_id("ready"), "test", "b", now_secs(), 3, &database).await);
1381        assert!(
1382            Job::revive_row(job_id("ready"), &database)
1383                .await
1384                .unwrap()
1385                .is_none()
1386        );
1387
1388        // max_attempts = 1 -> attempts back to 0 (a full single attempt).
1389        let one = failed_job_max(job_id("one"), "test", "c", 1, &database).await;
1390        assert_eq!(one.max_attempts, 1);
1391        let revived = Job::revive_row(job_id("one"), &database)
1392            .await
1393            .unwrap()
1394            .unwrap();
1395        assert_eq!(revived.attempts, 0);
1396    }
1397
1398    #[tokio::test]
1399    async fn find_latest_by_dedup_sees_a_settled_job_where_find_live_does_not() {
1400        let database = db().await;
1401        assert!(enqueue_kind(job_id("job-1"), "relay", "ord-1", now_secs(), 3, &database).await);
1402        let job = Job::claim_next("r", &["relay"], now_secs() + 60, now_secs(), &database)
1403            .await
1404            .unwrap()
1405            .unwrap();
1406        Job::complete(job.id, "r", &database).await.unwrap();
1407
1408        assert!(
1409            Job::find_live("relay", "ord-1", &database)
1410                .await
1411                .unwrap()
1412                .is_none()
1413        );
1414        let latest = Job::find_latest_by_dedup("relay", "ord-1", &database)
1415            .await
1416            .unwrap()
1417            .expect("the settled job is still resolvable");
1418        assert_eq!(latest.id, job_id("job-1"));
1419        assert_eq!(latest.status, "done");
1420    }
1421
1422    #[tokio::test]
1423    async fn revive_then_a_second_failure_stays_failed_without_another_nudge() {
1424        let database = db().await;
1425        failed_job(job_id("j"), "test", "a", &database).await;
1426        Job::revive_row(job_id("j"), &database)
1427            .await
1428            .unwrap()
1429            .unwrap();
1430        // Claim (attempts -> 3 = max) and abandon again.
1431        let job = Job::claim_next("r", &["test"], now_secs() + 60, now_secs(), &database)
1432            .await
1433            .unwrap()
1434            .unwrap();
1435        assert_eq!(job.attempts, 3);
1436        Job::abandon(job.id, "r", "again", &database).await.unwrap();
1437
1438        let after = Job::find_by_id(job_id("j"), &database)
1439            .await
1440            .unwrap()
1441            .unwrap();
1442        assert_eq!(after.status, "failed");
1443        // advance_row does nothing to a failed job; revive still works.
1444        assert!(
1445            Job::advance_row(job_id("j"), &database)
1446                .await
1447                .unwrap()
1448                .is_none()
1449        );
1450        assert!(
1451            Job::revive_row(job_id("j"), &database)
1452                .await
1453                .unwrap()
1454                .is_some()
1455        );
1456    }
1457}