Skip to main content

gunnar_server/
job_queue.rs

1//! **One worker, one job at a time, FIFO, deduplicated by subject — for any
2//! kind of job the box has to do slowly.**
3//!
4//! This module is the mechanism [`crate::gc_queue`] and [`crate::purge_queue`]
5//! were each carrying their own copy of. The two copies were 1165 lines with
6//! the same seven nouns in both — *dedup, enqueue, job_id, Queued, Running,
7//! Done, Failed, worker* — the same `Mutex`/`Condvar`/`VecDeque`, the same
8//! prune, the same in-flight instrument. What differed was two things and only
9//! two: **what the work is**, and **what has to happen when it ends**. Both are
10//! now the kind's business ([`JobKind::run`], [`JobKind::settle`]) and
11//! everything else is here, once.
12//!
13//! # Why a queue at all
14//!
15//! `Repos.Gc` and `Accounts.Purge` exist so a console button can start
16//! something expensive. The moment such a button exists, ten owners can press
17//! ten of them inside one second, and neither job is cheap: a gc seals the live
18//! logs, compacts the archive and unlinks the whole lazily-exploded read cache;
19//! a purge is minutes of unlink I/O across an account. Ten of those at once is
20//! not ten times the work, it is a box that has stopped answering.
21//!
22//! And the rule has to live in the server. A console can serialise **itself**.
23//! It cannot serialise the CLI, a second console, a script, or the next client
24//! anybody writes — and every one of those holds a credential that reaches the
25//! same disk. A rule only one participant obeys is not an invariant; it is a
26//! convention that holds until the first person who did not read it.
27//!
28//! # One worker per KIND, not one worker for the box
29//!
30//! [`JobQueue`] is a type, not a singleton: each kind gets its own instance and
31//! therefore its own jera lane (A32-14) and its own line. That is deliberate and it
32//! is the ruling `purge_queue.rs` already argued for, kept rather than quietly
33//! reversed by this extraction:
34//!
35//! **The line a job waits in is part of what the job costs.** A gc waits
36//! holding nothing — it is maintenance, it is re-derivable, and it can sit
37//! behind anything without costing anybody anything. A purge waits holding a
38//! **total lockout on a customer's account**, for the whole time it is queued.
39//! Put them in one line and a user is shut out of their account while the box
40//! grinds through an hour of somebody else's repository maintenance, because a
41//! gc arrived first. A mirror is a third answer again: it is network-bound, it
42//! blocks a repository that is *waiting to become usable*, and making it queue
43//! behind a compaction that is only holding the disk would be the same mistake
44//! in a third colour.
45//!
46//! So: one runner, three lines. What is shared is the mechanism and the
47//! vocabulary; what is not shared is the waiting.
48//!
49//! The honest limit of that ruling: two kinds CAN therefore run at once — a gc
50//! and a purge together is possible today and this change does not alter it —
51//! and both are heavy disk I/O. Nobody has measured that hurting, so nothing
52//! here pretends to fix it. If it ever does, the fix is a shared admission
53//! token taken around the [`JobKind::run`] call in [`work`] and nowhere else:
54//! one place, because there is now only one place.
55//!
56//! # What a finished job is for
57//!
58//! The worker does not drop a job when it finishes it. A console polls, and a
59//! poller that happened to be between requests at the instant the job completed
60//! would otherwise get "no such job" — indistinguishable from "you never
61//! asked". So a completed job is kept for [`JobKind::FINISHED_RETENTION`], and
62//! beyond that a bounded number of the most recent are kept anyway
63//! ([`JobKind::FINISHED_KEPT`]), so a busy server forgets by age and a quiet one
64//! does not forget at all.
65//!
66//! # Adding a kind
67//!
68//! Implement [`JobKind`]: name it, say what an order is, say what a report is,
69//! say which field of the order is the key, and write [`JobKind::run`].
70//! Everything else has a default. `tests/job_queue_third_kind.rs` is a whole
71//! kind declared **in a test crate**, which is the strongest available proof
72//! that a new kind needs no edit here — a test crate cannot change this file
73//! even by accident.
74
75use std::collections::{HashMap, VecDeque};
76use std::fmt;
77use std::ops::Deref;
78use std::panic::AssertUnwindSafe;
79use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
80use std::sync::{Arc, Mutex, Weak};
81use std::time::{Duration, SystemTime, UNIX_EPOCH};
82
83/// How long a finished job stays readable unless its kind says otherwise.
84///
85/// Ten minutes is far longer than any console poll interval and far shorter
86/// than a session, so a button that completed is still reporting its result
87/// when whoever pressed it looks back, and a server that has run thousands is
88/// not holding all of them.
89pub const DEFAULT_FINISHED_RETENTION: Duration = Duration::from_secs(600);
90
91/// Finished jobs kept regardless of age, most recent first.
92///
93/// The floor under [`DEFAULT_FINISHED_RETENTION`]: a server that has done
94/// nothing for an hour should still be able to say what its last job did.
95/// Bounded, because "keep everything" is how a long-lived process grows a leak
96/// with a good excuse.
97pub const DEFAULT_FINISHED_KEPT: usize = 64;
98
99/// Where a job is. One enum for every kind, because all three status RPCs
100/// render the same four words and a per-kind copy of them was three chances to
101/// disagree about what `Done` means.
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum JobState {
104    /// Waiting its turn.
105    Queued,
106    /// The one job of this kind that is running.
107    Running,
108    /// Finished; the kind's report is the answer.
109    Done,
110    /// Finished badly; `error` says why.
111    Failed,
112}
113
114impl JobState {
115    /// Whether a *new* request for the same subject should join this job rather
116    /// than start another.
117    ///
118    /// A queued or running job is the answer to "do this to this subject" — a
119    /// second request wants the same outcome and has nothing to add. A finished
120    /// one is not: the caller is asking for something that has not happened
121    /// *since*, and refusing them because a job ran an hour ago would be the
122    /// queue deciding it knows better.
123    pub fn is_live(self) -> bool {
124        matches!(self, JobState::Queued | JobState::Running)
125    }
126}
127
128/// **A kind of job.** The only thing a new kind has to write.
129///
130/// The queue owns waiting, ordering, dedup, the worker, the retention and the
131/// instruments. The kind owns what the work *is* and — if it took something
132/// while it ran — what has to be given back when it ends.
133pub trait JobKind: Send + Sync + Sized + 'static {
134    /// The kind's short name. It is the job-id prefix (`gc-7`, `purge-12`,
135    /// `mirror-3`), the jera lane's job kind (`gc`), and the noun in
136    /// every message this module writes — so an operator reading a stack dump
137    /// or a FAILED job knows what it was without a lookup.
138    const NAME: &'static str;
139
140    /// How long a finished job of this kind stays readable.
141    ///
142    /// Overridable rather than a shared constant because the two queues holding
143    /// the same number today is a coincidence and not a contract — a purge's
144    /// result is the more valuable of the two to still have when somebody looks
145    /// back, and a kind that decides so should be able to say it here rather
146    /// than by editing a number every kind shares.
147    const FINISHED_RETENTION: Duration = DEFAULT_FINISHED_RETENTION;
148
149    /// Finished jobs of this kind kept regardless of age.
150    const FINISHED_KEPT: usize = DEFAULT_FINISHED_KEPT;
151
152    /// **What was asked for**, carried on the job so the worker can hand it
153    /// back to [`JobKind::run`] minutes later.
154    ///
155    /// The whole order and not just the subject: a purge's `remove_principal`
156    /// and `expect_empty` change what the job *is*, and a queue that kept them
157    /// in a side map keyed by subject would be a second place the two could
158    /// disagree — with the more destructive reading winning whenever they did.
159    type Order: Clone + fmt::Debug + Send + Sync + 'static;
160
161    /// **What the job produced**, in the kind's own words: a gc's bytes, a
162    /// purge's stores and gate, a mirror's objects.
163    ///
164    /// The queue never looks inside it. It starts at [`Default::default`] —
165    /// which is why a job that never ran, or was refused, reports zeroes rather
166    /// than nothing.
167    type Report: Clone + fmt::Debug + Default + Send + Sync + 'static;
168
169    /// **The subject**, and therefore the dedup key and the status key.
170    ///
171    /// A repository path for a gc or a mirror, an account for a purge. Both
172    /// questions the queue answers are per subject — "is there already one for
173    /// this" on enqueue, "how is mine going" on status — so this is the only
174    /// key it keeps.
175    fn key(order: &Self::Order) -> &str;
176
177    /// **Do the work.** Runs on the lane's blocking thread, outside the queue's lock,
178    /// inside a [`std::panic::catch_unwind`].
179    ///
180    /// Report through `progress` as you go rather than in the return value: a
181    /// job of this sort runs for minutes with a console polling the whole time,
182    /// and a return value can only be read once the work is over — which is
183    /// precisely when nobody needs it any more.
184    fn run(&self, order: &Self::Order, progress: &Reporter<Self>) -> Result<(), String>;
185
186    /// **How the job settles — and the hook for anything that must come back.**
187    ///
188    /// Called on **every** path out of [`JobKind::run`]: after `Ok`, after
189    /// `Err`, and after a caught panic. That is the whole reason it is the
190    /// queue's job and not the runner's. A purge takes a total lockout on an
191    /// account before it is even enqueued and something has to lift it; a
192    /// runner that returned early, failed, or blew up would each be a different
193    /// missed unlock, and "release it in the error path too" is a rule that
194    /// holds until the next early return somebody adds.
195    ///
196    /// The default is the whole story for a kind that took nothing: `Ok` is
197    /// [`JobState::Done`], `Err` is [`JobState::Failed`] carrying the reason.
198    fn settle(
199        &self,
200        _order: &Self::Order,
201        outcome: Result<(), String>,
202        _progress: &Reporter<Self>,
203    ) -> (JobState, Option<String>) {
204        match outcome {
205            Ok(()) => (JobState::Done, None),
206            Err(why) => (JobState::Failed, Some(why)),
207        }
208    }
209
210    /// What a job refused by a shutting-down queue says.
211    ///
212    /// A shutting-down queue answers by name rather than accepting a job it
213    /// will never run: a console button lit for ever is worse than a refusal
214    /// that says why. Overridden by a kind whose caller has already paid
215    /// something for the job — a purge's caller is holding a lockout that
216    /// nothing is now going to lift, and has to be told so.
217    fn shutdown_error(&self) -> String {
218        format!("the {} queue is shutting down", Self::NAME)
219    }
220
221    /// What a job says when the kind's lane could not be started (no runtime).
222    ///
223    /// The queue cannot run anything without its lane, and a job that sits
224    /// QUEUED for ever is a console button that never goes down. Said on the
225    /// job itself.
226    fn spawn_error(&self, err: &dyn fmt::Display) -> String {
227        format!("the {} worker could not be started: {err}", Self::NAME)
228    }
229}
230
231/// One job of kind `K`, queued or completed.
232///
233/// The fields above the line are the queue's and are the same for every kind —
234/// which is what lets `GcStatus`, `PurgeStatus` and a future `MirrorStatus`
235/// render from one shape. `report` is the kind's own, and [`Job`] dereferences
236/// to it so a caller writes `job.bytes_reclaimed` for the gc's field and
237/// `job.state` for the queue's, without having to know which half it is in.
238pub struct Job<K: JobKind> {
239    pub job_id: String,
240    /// The subject, exactly as the queue keys on it — a repository path, an
241    /// account.
242    pub key: String,
243    pub state: JobState,
244    /// How many jobs are ahead of this one; 0 unless [`JobState::Queued`].
245    ///
246    /// Computed from the waiting list at read time and never stored, because a
247    /// stored position is wrong the moment the job in front of it starts, and a
248    /// queue that reported stale positions would be worse than one that
249    /// reported none.
250    pub queue_position: u32,
251    /// **How far along, as `(done, total)`** — whatever the kind counts in.
252    ///
253    /// `None` until the job reports one, and for kinds that never do: a gc and
254    /// a purge each finish in one indivisible act as far as a caller is
255    /// concerned, and a fabricated `0/1` would be a progress bar that lies. A
256    /// mirror cannot do without it — a fetch of a large repository is minutes
257    /// of network with somebody watching, and a job that sat at `Running` and
258    /// then jumped to `Done` tells that watcher nothing.
259    pub progress: Option<(u64, u64)>,
260    /// Only set when [`JobState::Failed`].
261    pub error: Option<String>,
262    /// What was asked for.
263    pub order: K::Order,
264    /// What the kind produced. Also reachable field-by-field through [`Deref`].
265    pub report: K::Report,
266    pub enqueued_at_unix_ms: i64,
267    /// 0 until the job leaves [`JobState::Queued`].
268    pub started_at_unix_ms: i64,
269    /// 0 until the job leaves [`JobState::Running`].
270    pub finished_at_unix_ms: i64,
271}
272
273/// **A job reads as one flat record**, the queue's fields and the kind's
274/// together.
275///
276/// Not inheritance-by-`Deref`: a [`Job`] is an envelope the queue wraps around
277/// exactly one `K::Report`, the way a newtype wraps its payload, and the target
278/// is a plain data struct with no methods of its own — so there is nothing here
279/// for a method call to resolve to by surprise. `job.report` is always
280/// available for anyone who would rather be explicit.
281impl<K: JobKind> Deref for Job<K> {
282    type Target = K::Report;
283
284    fn deref(&self) -> &K::Report {
285        &self.report
286    }
287}
288
289// Hand-written rather than derived: `derive` would demand `K: Clone` and
290// `K: Debug` of the KIND, which holds the runner closures and is neither.
291impl<K: JobKind> Clone for Job<K> {
292    fn clone(&self) -> Self {
293        Job {
294            job_id: self.job_id.clone(),
295            key: self.key.clone(),
296            state: self.state,
297            queue_position: self.queue_position,
298            progress: self.progress,
299            error: self.error.clone(),
300            order: self.order.clone(),
301            report: self.report.clone(),
302            enqueued_at_unix_ms: self.enqueued_at_unix_ms,
303            started_at_unix_ms: self.started_at_unix_ms,
304            finished_at_unix_ms: self.finished_at_unix_ms,
305        }
306    }
307}
308
309impl<K: JobKind> fmt::Debug for Job<K> {
310    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
311        f.debug_struct("Job")
312            .field("kind", &K::NAME)
313            .field("job_id", &self.job_id)
314            .field("key", &self.key)
315            .field("state", &self.state)
316            .field("queue_position", &self.queue_position)
317            .field("progress", &self.progress)
318            .field("error", &self.error)
319            .field("order", &self.order)
320            .field("report", &self.report)
321            .field("enqueued_at_unix_ms", &self.enqueued_at_unix_ms)
322            .field("started_at_unix_ms", &self.started_at_unix_ms)
323            .field("finished_at_unix_ms", &self.finished_at_unix_ms)
324            .finish()
325    }
326}
327
328/// **The handle a running job reports through.**
329///
330/// Every method takes `&self` and writes into the job under the queue's lock,
331/// so a console polling a status RPC sees a count that moves rather than a job
332/// that sits at zero for four minutes and then jumps.
333pub struct Reporter<K: JobKind> {
334    inner: Arc<Inner<K>>,
335    key: String,
336}
337
338impl<K: JobKind> Clone for Reporter<K> {
339    fn clone(&self) -> Self {
340        Reporter {
341            inner: Arc::clone(&self.inner),
342            key: self.key.clone(),
343        }
344    }
345}
346
347impl<K: JobKind> Reporter<K> {
348    /// The subject this job is working on, for a runner that wants it back.
349    pub fn key(&self) -> &str {
350        &self.key
351    }
352
353    /// **How far along**, in whatever the kind counts. Overwrites the previous
354    /// reading; a progress that accumulated across reports would be a second
355    /// counter to keep right.
356    pub fn progress(&self, done: u64, total: u64) {
357        self.edit(|job| job.progress = Some((done, total)));
358    }
359
360    /// Write into the kind's own report.
361    pub fn report(&self, apply: impl FnOnce(&mut K::Report)) {
362        self.edit(|job| apply(&mut job.report));
363    }
364
365    /// **The job as it stands right now**, for a runner that has to read back
366    /// what it reported.
367    ///
368    /// The one caller is the mirror runner, which publishes an event-stream event
369    /// carrying the reference list it wrote through [`Reporter::report`]. That
370    /// list is on the JOB — it has to be, because a status RPC renders it —
371    /// and a runner that also kept its own copy would be two places the same
372    /// facts could disagree (one implementation).
373    ///
374    /// `None` when the job is gone, which nothing does while a runner is
375    /// running: a live job is never pruned.
376    pub fn snapshot(&self) -> Option<Job<K>> {
377        let state = self.inner.state.lock().unwrap_or_else(|p| p.into_inner());
378        state.jobs.get(&self.key).cloned()
379    }
380
381    /// Edit the job record under the lock. Silently does nothing if the job is
382    /// gone, which nothing does today — a live job is never pruned.
383    fn edit(&self, apply: impl FnOnce(&mut Job<K>)) {
384        let mut state = self.inner.state.lock().unwrap_or_else(|p| p.into_inner());
385        if let Some(job) = state.jobs.get_mut(&self.key) {
386            apply(job);
387        }
388    }
389}
390
391/// The queue: one worker, one job at a time, FIFO, deduplicated by subject.
392pub struct JobQueue<K: JobKind> {
393    inner: Arc<Inner<K>>,
394}
395
396impl<K: JobKind> Clone for JobQueue<K> {
397    fn clone(&self) -> Self {
398        JobQueue {
399            inner: Arc::clone(&self.inner),
400        }
401    }
402}
403
404struct Inner<K: JobKind> {
405    state: Mutex<State<K>>,
406    /// **The kind's jera lane** (A32-14): one ChainQueue, one job at a time, FIFO, started on the
407    /// first enqueue on the caller's runtime (or gunnar-common's own); its worker task is ended
408    /// when the last handle of the queue goes.
409    lane: Mutex<
410        Option<(
411            Arc<jera::jobs::chain::ChainQueue>,
412            tokio::task::JoinHandle<()>,
413        )>,
414    >,
415    kind: K,
416    /// **Runs in flight right now**, and the highest that has ever been.
417    ///
418    /// The instrument the concurrency gate reads. It is bumped around the call
419    /// to [`JobKind::run`] rather than around the worker's bookkeeping, so what
420    /// a test observes is *jobs overlapping*, which is the thing that must
421    /// never happen — not the queue's opinion of how many are running.
422    in_flight: AtomicUsize,
423    peak_in_flight: AtomicUsize,
424    /// How many times the kind's `run` was CALLED. A dedup test that only
425    /// compared job ids could pass over a queue that ran the work twice under
426    /// one id; this is the applied output that cannot.
427    runs: AtomicU64,
428    seq: AtomicU64,
429}
430
431struct State<K: JobKind> {
432    /// Subject keys awaiting a turn, in the order they were asked for.
433    waiting: VecDeque<String>,
434    /// Every job the queue is holding, live and finished, keyed by SUBJECT and
435    /// not by job id — both questions anybody asks are per subject, and a job-id
436    /// map would be a second index over the same rows for no question at all.
437    jobs: HashMap<String, Job<K>>,
438    stopping: bool,
439}
440
441impl<K: JobKind> Default for State<K> {
442    fn default() -> Self {
443        State {
444            waiting: VecDeque::new(),
445            jobs: HashMap::new(),
446            stopping: false,
447        }
448    }
449}
450
451pub(crate) fn now_ms() -> i64 {
452    SystemTime::now()
453        .duration_since(UNIX_EPOCH)
454        .map(|d| d.as_millis() as i64)
455        .unwrap_or_default()
456}
457
458impl<K: JobKind> JobQueue<K> {
459    /// A queue over `kind`. No lane is started until something is enqueued.
460    pub fn new(kind: K) -> Self {
461        JobQueue {
462            inner: Arc::new(Inner {
463                state: Mutex::new(State::default()),
464                lane: Mutex::new(None),
465                kind,
466                in_flight: AtomicUsize::new(0),
467                peak_in_flight: AtomicUsize::new(0),
468                runs: AtomicU64::new(0),
469                seq: AtomicU64::new(0),
470            }),
471        }
472    }
473
474    /// **The job id the next enqueue would use**, without enqueuing.
475    ///
476    /// For a kind that has to name the job before it exists. A purge takes the
477    /// account's lockout *before* enqueuing — that is what makes the lockout a
478    /// fact by the time `Accounts.Purge` returns — and the lockout's holder has
479    /// to be the job id, or the release could not tell this job's lockout from
480    /// anybody else's.
481    ///
482    /// Minted from the same counter [`JobQueue::enqueue`] uses, so an id handed
483    /// out here and never used simply leaves a gap. A gap in a job-id sequence
484    /// costs nothing; two jobs sharing an id would make the release ambiguous.
485    pub fn next_job_id(&self) -> String {
486        self.mint()
487    }
488
489    /// **Ask for the order's subject to be worked on, and return without
490    /// working on it.**
491    ///
492    /// Returns the job now responsible for the subject: a new one, or the one
493    /// already queued or running. Never enqueues a second job for a subject
494    /// that has one live.
495    pub fn enqueue(&self, order: K::Order) -> Job<K> {
496        self.enqueue_inner(order, None)
497    }
498
499    /// [`JobQueue::enqueue`] under an id taken from [`JobQueue::next_job_id`]
500    /// earlier.
501    ///
502    /// The caller must not assume this id is the one that ends up responsible:
503    /// if a job for the same subject is already live, that one is returned and
504    /// this order's id is never used. `Accounts.Purge` handles exactly that by
505    /// reading the gate back out of the table rather than trusting the one it
506    /// wrote.
507    pub fn enqueue_as(&self, order: K::Order, job_id: String) -> Job<K> {
508        self.enqueue_inner(order, Some(job_id))
509    }
510
511    /// The job the queue is holding for `key`, if it still has one.
512    pub fn status(&self, key: &str) -> Option<Job<K>> {
513        let state = self.inner.state.lock().unwrap_or_else(|p| p.into_inner());
514        let job = state.jobs.get(key)?.clone();
515        Some(with_position(&state, job))
516    }
517
518    /// The highest number of jobs of this kind that have ever been running
519    /// together.
520    ///
521    /// **Must be 1 after any amount of traffic** — that is the invariant this
522    /// module exists for, and this is how a test sees it rather than infers it.
523    /// 0 before anything has run.
524    pub fn peak_in_flight(&self) -> usize {
525        self.inner.peak_in_flight.load(Ordering::SeqCst)
526    }
527
528    /// How many times the kind's `run` has been called — i.e. how much work
529    /// actually happened. The applied output the dedup gate asserts on.
530    pub fn runs(&self) -> u64 {
531        self.inner.runs.load(Ordering::SeqCst)
532    }
533
534    /// Run nothing more after the job in flight, and refuse new jobs by name. For tests and the
535    /// box's stop; the lane's worker ends when the last handle of the queue goes.
536    pub fn stop(&self) {
537        let mut state = self.inner.state.lock().unwrap_or_else(|p| p.into_inner());
538        state.stopping = true;
539    }
540
541    /// **The queue's jera lane**, counted: its done and failed jobs (A32-14). `None` before the
542    /// first job, when no lane has started.
543    pub fn jera_stats(&self) -> Option<jera::jobs::chain::ChainStats> {
544        self.inner
545            .lane
546            .lock()
547            .unwrap_or_else(|p| p.into_inner())
548            .as_ref()
549            .map(|(q, _)| q.stats())
550    }
551
552    /// **The kind's lane**, started on the first ask: a jera ChainQueue whose worker runs one job
553    /// at a time on the runtime's blocking pool. Held weakly by its worker, so a queue nobody
554    /// holds any more ends it. `Err` only when no runtime can be had.
555    fn lane(&self) -> Result<Arc<jera::jobs::chain::ChainQueue>, String> {
556        let mut lane = self.inner.lane.lock().unwrap_or_else(|p| p.into_inner());
557        if let Some((q, _)) = lane.as_ref() {
558            return Ok(Arc::clone(q));
559        }
560        let rt = gunnar_common::jobs::handle()?;
561        // Every gc, purge and mirror is a row in the box's ledger once it is open (RA3-5).
562        // jera's own re-ask (RA3-8: jera 0.2.1 looks at the exclusive gate and never takes it).
563        let queue = jera::jobs::chain::ChainQueue::new(gunnar_common::jobs::ledger_sink());
564        let weak: Weak<Inner<K>> = Arc::downgrade(&self.inner);
565        let worker = rt.spawn(Arc::clone(&queue).run(move |spec| {
566            let key = spec
567                .payload
568                .get("key")
569                .and_then(|v| v.as_str())
570                .unwrap_or_default()
571                .to_owned();
572            let Some(inner) = weak.upgrade() else {
573                anyhow::bail!("the {} queue is gone", K::NAME)
574            };
575            run_one(&inner, &key).map_err(|why| anyhow::anyhow!(why))
576        }));
577        *lane = Some((Arc::clone(&queue), worker));
578        Ok(queue)
579    }
580
581    fn mint(&self) -> String {
582        let id = self.inner.seq.fetch_add(1, Ordering::SeqCst) + 1;
583        format!("{}-{id}", K::NAME)
584    }
585
586    fn enqueue_inner(&self, order: K::Order, preminted: Option<String>) -> Job<K> {
587        let key = K::key(&order).to_owned();
588        let mut state = self.inner.state.lock().unwrap_or_else(|p| p.into_inner());
589        if state.stopping {
590            let now = now_ms();
591            let refused: Job<K> = Job {
592                // Whatever the caller already named the job, so a purge's
593                // caller can still say which lockout is stranded. Empty for a
594                // kind that had not named one yet.
595                job_id: preminted.unwrap_or_default(),
596                key,
597                state: JobState::Failed,
598                queue_position: 0,
599                progress: None,
600                error: Some(self.inner.kind.shutdown_error()),
601                order,
602                report: K::Report::default(),
603                enqueued_at_unix_ms: now,
604                started_at_unix_ms: 0,
605                finished_at_unix_ms: now,
606            };
607            return refused;
608        }
609        if let Some(existing) = state.jobs.get(&key) {
610            if existing.state.is_live() {
611                // **The double press.** Not an error and not a new job — the
612                // caller wants this subject dealt with and something is already
613                // going to do that.
614                let job = existing.clone();
615                return with_position(&state, job);
616            }
617        }
618        // Minted here, AFTER the dedup check, so an id is spent only on a job
619        // that exists. A kind that had to name the job earlier passes its id in.
620        let job_id = match preminted {
621            Some(id) => id,
622            None => self.mint(),
623        };
624        let job: Job<K> = Job {
625            job_id,
626            key: key.clone(),
627            state: JobState::Queued,
628            queue_position: 0,
629            progress: None,
630            error: None,
631            order,
632            report: K::Report::default(),
633            enqueued_at_unix_ms: now_ms(),
634            started_at_unix_ms: 0,
635            finished_at_unix_ms: 0,
636        };
637        state.jobs.insert(key.clone(), job.clone());
638        state.waiting.push_back(key.clone());
639        // **A jera job on the kind's lane** (A32-14), one per job, keyed by its id: the lane runs
640        // them one at a time in the order asked, each a row that ends DONE or FAILED.
641        let lane = match self.lane() {
642            Ok(lane) => lane,
643            Err(err) => {
644                state.waiting.pop_back();
645                let message = self.inner.kind.spawn_error(&err);
646                let failed = state.jobs.get_mut(&key).expect("just inserted");
647                failed.state = JobState::Failed;
648                failed.error = Some(message);
649                failed.finished_at_unix_ms = now_ms();
650                return failed.clone();
651            }
652        };
653        let spec = jera::jobs::chain::ChainSpec::new(
654            K::NAME,
655            job.job_id.clone(),
656            K::NAME,
657            0,
658            "gunnar",
659            serde_json::json!({ "key": key }),
660        )
661        .with_target(format!("{} {key}", K::NAME))
662        .with_priority(jera::jobs::chain::Priority::Normal);
663        let _ = lane.submit(spec);
664        with_position(&state, job)
665    }
666}
667
668/// Fill in `queue_position` from the waiting list.
669fn with_position<K: JobKind>(state: &State<K>, mut job: Job<K>) -> Job<K> {
670    job.queue_position = if job.state == JobState::Queued {
671        state
672            .waiting
673            .iter()
674            .position(|k| *k == job.key)
675            .map(|n| n as u32)
676            .unwrap_or(0)
677    } else {
678        0
679    };
680    job
681}
682
683/// **One job of the kind's lane** — the only place a job of this kind is started, and its lane
684/// runs one at a time. `Err` (a FAILED jera row) when the job ended FAILED or panicked; a
685/// stopping queue runs nothing (the job stays queued, as the old worker left it).
686fn run_one<K: JobKind>(inner: &Arc<Inner<K>>, key: &str) -> Result<serde_json::Value, String> {
687    let (order, job_id) = {
688        let mut state = inner.state.lock().unwrap_or_else(|p| p.into_inner());
689        if state.stopping {
690            return Ok(
691                serde_json::json!({ "key": key, "ran": false, "why": "the queue is stopping" }),
692            );
693        }
694        if let Some(at) = state.waiting.iter().position(|k| k == key) {
695            state.waiting.remove(at);
696        }
697        match state.jobs.get_mut(key) {
698            Some(job) if job.state == JobState::Queued => {
699                job.state = JobState::Running;
700                job.started_at_unix_ms = now_ms();
701                (job.order.clone(), job.job_id.clone())
702            }
703            // The job was pruned or already run, which nothing does today. Skip rather than
704            // invent an order: a purge run from a default would be a purge nobody asked for.
705            _ => return Ok(serde_json::json!({ "key": key, "ran": false })),
706        }
707    };
708    let progress = Reporter {
709        inner: Arc::clone(inner),
710        key: key.to_owned(),
711    };
712
713    // ── outside the lock, deliberately ─────────────────────────────────
714    //
715    // These jobs take minutes. Holding the state lock across one would
716    // block every status poll and every enqueue for the whole run, so the
717    // console button this queue exists to serve would go grey exactly when
718    // somebody is watching it.
719    let live = inner.in_flight.fetch_add(1, Ordering::SeqCst) + 1;
720    inner.peak_in_flight.fetch_max(live, Ordering::SeqCst);
721    inner.runs.fetch_add(1, Ordering::SeqCst);
722    // **The panic is caught here and not left to unwind**: an unwinding job never reaches
723    // `settle` below, so whatever it took — a purge's lockout on a customer's account — would
724    // never be given back. Said as an ALERT line, and the lane's next job runs.
725    //
726    // `AssertUnwindSafe` because the kind is a `&dyn`-shaped thing we do
727    // not own and cannot prove unwind-safe. What it touches on this side of
728    // the boundary is the job record, and the worst a half-applied progress
729    // report leaves is a count that is short — which the FAILED state and
730    // the panic message already say not to trust.
731    let outcome =
732        match std::panic::catch_unwind(AssertUnwindSafe(|| inner.kind.run(&order, &progress))) {
733            Ok(result) => result,
734            Err(panic) => {
735                let why = format!("the {} panicked: {}", K::NAME, panic_text(&panic));
736                tracing::error!("ALERT gunnar-{} job {job_id} ({key}): {why}", K::NAME);
737                Err(why)
738            }
739        };
740    inner.in_flight.fetch_sub(1, Ordering::SeqCst);
741
742    // ── the settle, on every path ──────────────────────────────────────
743    //
744    // Unconditional, and it runs after a success, after a failure and after
745    // a panic. This is where a kind gives back whatever it took.
746    let (state_out, error) = inner.kind.settle(&order, outcome, &progress);
747
748    let mut state = inner.state.lock().unwrap_or_else(|p| p.into_inner());
749    if let Some(job) = state.jobs.get_mut(key) {
750        job.state = state_out;
751        job.error = error.clone();
752        job.finished_at_unix_ms = now_ms();
753    }
754    prune::<K>(&mut state);
755    match state_out {
756        JobState::Failed => Err(error.unwrap_or_else(|| format!("the {} job failed", K::NAME))),
757        _ => Ok(serde_json::json!({ "key": key, "job": job_id })),
758    }
759}
760
761impl<K: JobKind> Drop for Inner<K> {
762    /// The last handle of the queue is gone: its lane's idle worker ends with it.
763    fn drop(&mut self) {
764        if let Some((_, worker)) = self.lane.lock().unwrap_or_else(|p| p.into_inner()).take() {
765            worker.abort();
766        }
767    }
768}
769
770/// Forget finished jobs that are old, and the oldest of them once there are too
771/// many. Live jobs are never pruned — a queue that forgot what it was about to
772/// do would be a leak of a different kind, and a console polling a job that is
773/// still running would get "no such job".
774fn prune<K: JobKind>(state: &mut State<K>) {
775    let now = now_ms();
776    let cutoff = now - K::FINISHED_RETENTION.as_millis() as i64;
777    let mut finished: Vec<(String, i64)> = state
778        .jobs
779        .iter()
780        .filter(|(_, job)| !job.state.is_live())
781        .map(|(key, job)| (key.clone(), job.finished_at_unix_ms))
782        .collect();
783    // Newest first, so the keep-the-last-N floor keeps the useful ones.
784    finished.sort_by_key(|job| std::cmp::Reverse(job.1));
785    for (index, (key, finished_at)) in finished.into_iter().enumerate() {
786        if index >= K::FINISHED_KEPT || finished_at < cutoff {
787            state.jobs.remove(&key);
788        }
789    }
790}
791
792/// The message out of a caught panic, or a stand-in when it is neither of the
793/// two shapes `panic!` produces.
794pub(crate) fn panic_text(panic: &Box<dyn std::any::Any + Send>) -> String {
795    if let Some(text) = panic.downcast_ref::<&'static str>() {
796        return (*text).to_owned();
797    }
798    if let Some(text) = panic.downcast_ref::<String>() {
799        return text.clone();
800    }
801    "a panic with no message".to_owned()
802}
803
804#[cfg(test)]
805mod tests {
806    use super::*;
807
808    /// A kind that does nothing, so the bookkeeping below is about the queue
809    /// and not about any real work.
810    struct Nothing;
811
812    impl JobKind for Nothing {
813        const NAME: &'static str = "nothing";
814        type Order = String;
815        type Report = ();
816
817        fn key(order: &String) -> &str {
818            order
819        }
820
821        fn run(&self, _order: &String, _progress: &Reporter<Self>) -> Result<(), String> {
822            Ok(())
823        }
824    }
825
826    /// A kind whose `boom` panics and whose `bad` fails.
827    struct Flaky;
828
829    impl JobKind for Flaky {
830        const NAME: &'static str = "flaky";
831        type Order = String;
832        type Report = ();
833
834        fn key(order: &String) -> &str {
835            order
836        }
837
838        fn run(&self, order: &String, _progress: &Reporter<Self>) -> Result<(), String> {
839            match order.as_str() {
840                "boom" => panic!("the boom job breaks"),
841                "bad" => Err("the bad job fails".to_owned()),
842                _ => Ok(()),
843            }
844        }
845    }
846
847    /// ★ **The queue's jobs are jera jobs** (A32-14): each one is a row on the queue's jera lane,
848    /// a panicked or failed one a FAILED row, and the next job runs; still one at a time, FIFO.
849    #[test]
850    fn the_jobs_run_as_jera_jobs_and_a_panic_is_a_failed_one() {
851        let q = JobQueue::new(Flaky);
852        for k in ["a", "boom", "b", "bad", "c"] {
853            q.enqueue(k.to_owned());
854        }
855        let t = std::time::Instant::now();
856        while ["a", "boom", "b", "bad", "c"]
857            .iter()
858            .any(|k| q.status(k).is_none_or(|j| j.state.is_live()))
859            && t.elapsed() < Duration::from_secs(10)
860        {
861            std::thread::sleep(Duration::from_millis(10));
862        }
863        let state = |k: &str| q.status(k).map(|j| j.state);
864        assert_eq!(
865            [
866                state("a"),
867                state("boom"),
868                state("b"),
869                state("bad"),
870                state("c")
871            ],
872            [
873                Some(JobState::Done),
874                Some(JobState::Failed),
875                Some(JobState::Done),
876                Some(JobState::Failed),
877                Some(JobState::Done)
878            ]
879        );
880        assert!(
881            q.status("boom")
882                .and_then(|j| j.error)
883                .unwrap_or_default()
884                .contains("the boom job breaks"),
885            "the panic's words are on the job"
886        );
887        let s = q.jera_stats().expect("the queue runs on a jera lane");
888        assert_eq!(
889            (s.done, s.failed),
890            (3, 2),
891            "three done and two failed jera jobs: {s:?}"
892        );
893        assert_eq!(q.peak_in_flight(), 1, "one at a time");
894        q.stop();
895    }
896
897    /// ★ **A stopped queue refuses by name and runs nothing more**; its lane says so.
898    #[test]
899    fn a_stopped_queue_refuses_and_runs_nothing_more() {
900        let q = JobQueue::new(Flaky);
901        q.enqueue("a".to_owned());
902        let t = std::time::Instant::now();
903        while q.status("a").is_none_or(|j| j.state.is_live())
904            && t.elapsed() < Duration::from_secs(10)
905        {
906            std::thread::sleep(Duration::from_millis(10));
907        }
908        assert!(
909            q.jera_stats().is_some_and(|s| s.done == 1),
910            "the first job is a done jera job"
911        );
912        q.stop();
913        let refused = q.enqueue("b".to_owned());
914        assert_eq!(refused.state, JobState::Failed);
915        assert!(refused.error.unwrap_or_default().contains("shutting down"));
916        std::thread::sleep(Duration::from_millis(100));
917        assert_eq!(q.runs(), 1, "nothing ran after the stop");
918    }
919
920    fn finished(key: &str, age: i64) -> Job<Nothing> {
921        Job {
922            job_id: format!("nothing-{key}"),
923            key: key.to_owned(),
924            state: JobState::Done,
925            queue_position: 0,
926            progress: None,
927            error: None,
928            order: key.to_owned(),
929            report: (),
930            enqueued_at_unix_ms: 0,
931            started_at_unix_ms: 0,
932            // Newer as `age` grows.
933            finished_at_unix_ms: now_ms() + age,
934        }
935    }
936
937    /// `prune` keeps the newest and drops the rest — asserted on which jobs
938    /// survive, not on the count, so a prune that kept the wrong ones is red.
939    #[test]
940    fn prune_keeps_the_newest_finished_jobs() {
941        let mut state = State::<Nothing>::default();
942        for n in 0..(Nothing::FINISHED_KEPT + 5) {
943            let key = format!("ns/repo{n}");
944            state.jobs.insert(key.clone(), finished(&key, n as i64));
945        }
946        prune::<Nothing>(&mut state);
947        assert_eq!(state.jobs.len(), Nothing::FINISHED_KEPT);
948        assert!(
949            state
950                .jobs
951                .contains_key(&format!("ns/repo{}", Nothing::FINISHED_KEPT + 4)),
952            "the newest finished job must survive"
953        );
954        assert!(
955            !state.jobs.contains_key("ns/repo0"),
956            "the oldest finished job must be dropped"
957        );
958    }
959
960    /// A live job is never pruned, however many finished ones crowd it out.
961    #[test]
962    fn prune_never_drops_a_live_job() {
963        let mut state = State::<Nothing>::default();
964        let mut running = finished("ns/running", 0);
965        running.state = JobState::Running;
966        running.finished_at_unix_ms = 0;
967        state.jobs.insert("ns/running".into(), running);
968        for n in 0..(Nothing::FINISHED_KEPT + 5) {
969            let key = format!("ns/repo{n}");
970            state.jobs.insert(key.clone(), finished(&key, n as i64));
971        }
972        prune::<Nothing>(&mut state);
973        assert!(
974            state.jobs.contains_key("ns/running"),
975            "a running job must survive a prune; forgetting it would leave a \
976             console button lit for ever"
977        );
978    }
979
980    /// A panic carrying a `String` and one carrying a `&'static str` both come
981    /// back as their message. Asserted because the fallback text is what an
982    /// operator would otherwise read instead of the cause.
983    #[test]
984    fn a_panics_message_survives_being_caught() {
985        let owned: Box<dyn std::any::Any + Send> = Box::new("a static message".to_string());
986        assert_eq!(panic_text(&owned), "a static message");
987        let borrowed: Box<dyn std::any::Any + Send> = Box::new("a literal message");
988        assert_eq!(panic_text(&borrowed), "a literal message");
989        let odd: Box<dyn std::any::Any + Send> = Box::new(7u32);
990        assert_eq!(panic_text(&odd), "a panic with no message");
991    }
992}