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}