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 worker thread 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, Condvar, Mutex};
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 worker thread's name (`gunnar-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 worker 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 worker thread could not be spawned.
222 ///
223 /// The queue cannot run anything without its worker, 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: &std::io::Error) -> 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 /// Woken when a job is enqueued, and when the queue is asked to stop.
407 wake: Condvar,
408 kind: K,
409 /// **Runs in flight right now**, and the highest that has ever been.
410 ///
411 /// The instrument the concurrency gate reads. It is bumped around the call
412 /// to [`JobKind::run`] rather than around the worker's bookkeeping, so what
413 /// a test observes is *jobs overlapping*, which is the thing that must
414 /// never happen — not the queue's opinion of how many are running.
415 in_flight: AtomicUsize,
416 peak_in_flight: AtomicUsize,
417 /// How many times the kind's `run` was CALLED. A dedup test that only
418 /// compared job ids could pass over a queue that ran the work twice under
419 /// one id; this is the applied output that cannot.
420 runs: AtomicU64,
421 seq: AtomicU64,
422}
423
424struct State<K: JobKind> {
425 /// Subject keys awaiting a turn, in the order they were asked for.
426 waiting: VecDeque<String>,
427 /// Every job the queue is holding, live and finished, keyed by SUBJECT and
428 /// not by job id — both questions anybody asks are per subject, and a job-id
429 /// map would be a second index over the same rows for no question at all.
430 jobs: HashMap<String, Job<K>>,
431 /// Whether the worker thread has been started. Started on first use, so a
432 /// plane built by a test that never enqueues costs no thread.
433 started: bool,
434 stopping: bool,
435}
436
437impl<K: JobKind> Default for State<K> {
438 fn default() -> Self {
439 State {
440 waiting: VecDeque::new(),
441 jobs: HashMap::new(),
442 started: false,
443 stopping: false,
444 }
445 }
446}
447
448pub(crate) fn now_ms() -> i64 {
449 SystemTime::now()
450 .duration_since(UNIX_EPOCH)
451 .map(|d| d.as_millis() as i64)
452 .unwrap_or_default()
453}
454
455impl<K: JobKind> JobQueue<K> {
456 /// A queue over `kind`. No thread is started until something is enqueued.
457 pub fn new(kind: K) -> Self {
458 JobQueue {
459 inner: Arc::new(Inner {
460 state: Mutex::new(State::default()),
461 wake: Condvar::new(),
462 kind,
463 in_flight: AtomicUsize::new(0),
464 peak_in_flight: AtomicUsize::new(0),
465 runs: AtomicU64::new(0),
466 seq: AtomicU64::new(0),
467 }),
468 }
469 }
470
471 /// **The job id the next enqueue would use**, without enqueuing.
472 ///
473 /// For a kind that has to name the job before it exists. A purge takes the
474 /// account's lockout *before* enqueuing — that is what makes the lockout a
475 /// fact by the time `Accounts.Purge` returns — and the lockout's holder has
476 /// to be the job id, or the release could not tell this job's lockout from
477 /// anybody else's.
478 ///
479 /// Minted from the same counter [`JobQueue::enqueue`] uses, so an id handed
480 /// out here and never used simply leaves a gap. A gap in a job-id sequence
481 /// costs nothing; two jobs sharing an id would make the release ambiguous.
482 pub fn next_job_id(&self) -> String {
483 self.mint()
484 }
485
486 /// **Ask for the order's subject to be worked on, and return without
487 /// working on it.**
488 ///
489 /// Returns the job now responsible for the subject: a new one, or the one
490 /// already queued or running. Never enqueues a second job for a subject
491 /// that has one live.
492 pub fn enqueue(&self, order: K::Order) -> Job<K> {
493 self.enqueue_inner(order, None)
494 }
495
496 /// [`JobQueue::enqueue`] under an id taken from [`JobQueue::next_job_id`]
497 /// earlier.
498 ///
499 /// The caller must not assume this id is the one that ends up responsible:
500 /// if a job for the same subject is already live, that one is returned and
501 /// this order's id is never used. `Accounts.Purge` handles exactly that by
502 /// reading the gate back out of the table rather than trusting the one it
503 /// wrote.
504 pub fn enqueue_as(&self, order: K::Order, job_id: String) -> Job<K> {
505 self.enqueue_inner(order, Some(job_id))
506 }
507
508 /// The job the queue is holding for `key`, if it still has one.
509 pub fn status(&self, key: &str) -> Option<Job<K>> {
510 let state = self.inner.state.lock().unwrap_or_else(|p| p.into_inner());
511 let job = state.jobs.get(key)?.clone();
512 Some(with_position(&state, job))
513 }
514
515 /// The highest number of jobs of this kind that have ever been running
516 /// together.
517 ///
518 /// **Must be 1 after any amount of traffic** — that is the invariant this
519 /// module exists for, and this is how a test sees it rather than infers it.
520 /// 0 before anything has run.
521 pub fn peak_in_flight(&self) -> usize {
522 self.inner.peak_in_flight.load(Ordering::SeqCst)
523 }
524
525 /// How many times the kind's `run` has been called — i.e. how much work
526 /// actually happened. The applied output the dedup gate asserts on.
527 pub fn runs(&self) -> u64 {
528 self.inner.runs.load(Ordering::SeqCst)
529 }
530
531 /// Stop the worker after the job it is on. For tests and shutdown; a queue
532 /// dropped without this simply leaves its thread parked on the condvar
533 /// until the process ends.
534 pub fn stop(&self) {
535 let mut state = self.inner.state.lock().unwrap_or_else(|p| p.into_inner());
536 state.stopping = true;
537 self.inner.wake.notify_all();
538 }
539
540 fn mint(&self) -> String {
541 let id = self.inner.seq.fetch_add(1, Ordering::SeqCst) + 1;
542 format!("{}-{id}", K::NAME)
543 }
544
545 fn enqueue_inner(&self, order: K::Order, preminted: Option<String>) -> Job<K> {
546 let key = K::key(&order).to_owned();
547 let mut state = self.inner.state.lock().unwrap_or_else(|p| p.into_inner());
548 if state.stopping {
549 let now = now_ms();
550 let refused: Job<K> = Job {
551 // Whatever the caller already named the job, so a purge's
552 // caller can still say which lockout is stranded. Empty for a
553 // kind that had not named one yet.
554 job_id: preminted.unwrap_or_default(),
555 key,
556 state: JobState::Failed,
557 queue_position: 0,
558 progress: None,
559 error: Some(self.inner.kind.shutdown_error()),
560 order,
561 report: K::Report::default(),
562 enqueued_at_unix_ms: now,
563 started_at_unix_ms: 0,
564 finished_at_unix_ms: now,
565 };
566 return refused;
567 }
568 if let Some(existing) = state.jobs.get(&key) {
569 if existing.state.is_live() {
570 // **The double press.** Not an error and not a new job — the
571 // caller wants this subject dealt with and something is already
572 // going to do that.
573 let job = existing.clone();
574 return with_position(&state, job);
575 }
576 }
577 // Minted here, AFTER the dedup check, so an id is spent only on a job
578 // that exists. A kind that had to name the job earlier passes its id in.
579 let job_id = match preminted {
580 Some(id) => id,
581 None => self.mint(),
582 };
583 let job: Job<K> = Job {
584 job_id,
585 key: key.clone(),
586 state: JobState::Queued,
587 queue_position: 0,
588 progress: None,
589 error: None,
590 order,
591 report: K::Report::default(),
592 enqueued_at_unix_ms: now_ms(),
593 started_at_unix_ms: 0,
594 finished_at_unix_ms: 0,
595 };
596 state.jobs.insert(key.clone(), job.clone());
597 state.waiting.push_back(key.clone());
598 if !state.started {
599 state.started = true;
600 let inner = Arc::clone(&self.inner);
601 // A named thread, because "which thread is holding the disk" is the
602 // first question anyone asks of a stack dump on a busy box.
603 let spawned = std::thread::Builder::new()
604 .name(format!("gunnar-{}", K::NAME))
605 .spawn(move || work(inner));
606 if let Err(err) = spawned {
607 state.started = false;
608 state.waiting.pop_back();
609 let message = self.inner.kind.spawn_error(&err);
610 let failed = state.jobs.get_mut(&key).expect("just inserted");
611 failed.state = JobState::Failed;
612 failed.error = Some(message);
613 failed.finished_at_unix_ms = now_ms();
614 return failed.clone();
615 }
616 }
617 self.inner.wake.notify_all();
618 with_position(&state, job)
619 }
620}
621
622/// Fill in `queue_position` from the waiting list.
623fn with_position<K: JobKind>(state: &State<K>, mut job: Job<K>) -> Job<K> {
624 job.queue_position = if job.state == JobState::Queued {
625 state
626 .waiting
627 .iter()
628 .position(|k| *k == job.key)
629 .map(|n| n as u32)
630 .unwrap_or(0)
631 } else {
632 0
633 };
634 job
635}
636
637/// The worker. **The only place a job of this kind is started**, and there is
638/// one of it.
639fn work<K: JobKind>(inner: Arc<Inner<K>>) {
640 loop {
641 let key = {
642 let mut state = inner.state.lock().unwrap_or_else(|p| p.into_inner());
643 loop {
644 if state.stopping {
645 state.started = false;
646 return;
647 }
648 if let Some(key) = state.waiting.pop_front() {
649 if let Some(job) = state.jobs.get_mut(&key) {
650 job.state = JobState::Running;
651 job.started_at_unix_ms = now_ms();
652 }
653 break key;
654 }
655 state = inner.wake.wait(state).unwrap_or_else(|p| p.into_inner());
656 }
657 };
658
659 let order = {
660 let state = inner.state.lock().unwrap_or_else(|p| p.into_inner());
661 match state.jobs.get(&key) {
662 Some(job) => job.order.clone(),
663 // The job was pruned between being taken off the queue and
664 // being read back, which nothing does today. Skip rather than
665 // invent an order: a purge run from a default would be a purge
666 // nobody asked for.
667 None => continue,
668 }
669 };
670 let progress = Reporter {
671 inner: Arc::clone(&inner),
672 key: key.clone(),
673 };
674
675 // ── outside the lock, deliberately ─────────────────────────────────
676 //
677 // These jobs take minutes. Holding the state lock across one would
678 // block every status poll and every enqueue for the whole run, so the
679 // console button this queue exists to serve would go grey exactly when
680 // somebody is watching it.
681 let live = inner.in_flight.fetch_add(1, Ordering::SeqCst) + 1;
682 inner.peak_in_flight.fetch_max(live, Ordering::SeqCst);
683 inner.runs.fetch_add(1, Ordering::SeqCst);
684 // **The panic is caught here and not left to unwind**, for two reasons
685 // and both of them are subjects nobody can use. First, an unwinding
686 // worker never reaches `settle` below, so whatever the job took — a
687 // purge's lockout on a customer's account — is never given back.
688 // Second, the worker thread would be gone, `started` would still be
689 // true, and every subject queued after it would sit QUEUED for ever
690 // behind a thread that does not exist: one subject's bug bricking the
691 // next one's.
692 //
693 // `AssertUnwindSafe` because the kind is a `&dyn`-shaped thing we do
694 // not own and cannot prove unwind-safe. What it touches on this side of
695 // the boundary is the job record, and the worst a half-applied progress
696 // report leaves is a count that is short — which the FAILED state and
697 // the panic message already say not to trust.
698 let outcome =
699 match std::panic::catch_unwind(AssertUnwindSafe(|| inner.kind.run(&order, &progress))) {
700 Ok(result) => result,
701 Err(panic) => Err(format!(
702 "the {} panicked: {}",
703 K::NAME,
704 panic_text(&panic)
705 )),
706 };
707 inner.in_flight.fetch_sub(1, Ordering::SeqCst);
708
709 // ── the settle, on every path ──────────────────────────────────────
710 //
711 // Unconditional, and it runs after a success, after a failure and after
712 // a panic. This is where a kind gives back whatever it took.
713 let (state_out, error) = inner.kind.settle(&order, outcome, &progress);
714
715 let mut state = inner.state.lock().unwrap_or_else(|p| p.into_inner());
716 if let Some(job) = state.jobs.get_mut(&key) {
717 job.state = state_out;
718 job.error = error;
719 job.finished_at_unix_ms = now_ms();
720 }
721 prune::<K>(&mut state);
722 }
723}
724
725/// Forget finished jobs that are old, and the oldest of them once there are too
726/// many. Live jobs are never pruned — a queue that forgot what it was about to
727/// do would be a leak of a different kind, and a console polling a job that is
728/// still running would get "no such job".
729fn prune<K: JobKind>(state: &mut State<K>) {
730 let now = now_ms();
731 let cutoff = now - K::FINISHED_RETENTION.as_millis() as i64;
732 let mut finished: Vec<(String, i64)> = state
733 .jobs
734 .iter()
735 .filter(|(_, job)| !job.state.is_live())
736 .map(|(key, job)| (key.clone(), job.finished_at_unix_ms))
737 .collect();
738 // Newest first, so the keep-the-last-N floor keeps the useful ones.
739 finished.sort_by_key(|job| std::cmp::Reverse(job.1));
740 for (index, (key, finished_at)) in finished.into_iter().enumerate() {
741 if index >= K::FINISHED_KEPT || finished_at < cutoff {
742 state.jobs.remove(&key);
743 }
744 }
745}
746
747/// The message out of a caught panic, or a stand-in when it is neither of the
748/// two shapes `panic!` produces.
749pub(crate) fn panic_text(panic: &Box<dyn std::any::Any + Send>) -> String {
750 if let Some(text) = panic.downcast_ref::<&'static str>() {
751 return (*text).to_owned();
752 }
753 if let Some(text) = panic.downcast_ref::<String>() {
754 return text.clone();
755 }
756 "a panic with no message".to_owned()
757}
758
759#[cfg(test)]
760mod tests {
761 use super::*;
762
763 /// A kind that does nothing, so the bookkeeping below is about the queue
764 /// and not about any real work.
765 struct Nothing;
766
767 impl JobKind for Nothing {
768 const NAME: &'static str = "nothing";
769 type Order = String;
770 type Report = ();
771
772 fn key(order: &String) -> &str {
773 order
774 }
775
776 fn run(&self, _order: &String, _progress: &Reporter<Self>) -> Result<(), String> {
777 Ok(())
778 }
779 }
780
781 fn finished(key: &str, age: i64) -> Job<Nothing> {
782 Job {
783 job_id: format!("nothing-{key}"),
784 key: key.to_owned(),
785 state: JobState::Done,
786 queue_position: 0,
787 progress: None,
788 error: None,
789 order: key.to_owned(),
790 report: (),
791 enqueued_at_unix_ms: 0,
792 started_at_unix_ms: 0,
793 // Newer as `age` grows.
794 finished_at_unix_ms: now_ms() + age,
795 }
796 }
797
798 /// `prune` keeps the newest and drops the rest — asserted on which jobs
799 /// survive, not on the count, so a prune that kept the wrong ones is red.
800 #[test]
801 fn prune_keeps_the_newest_finished_jobs() {
802 let mut state = State::<Nothing>::default();
803 for n in 0..(Nothing::FINISHED_KEPT + 5) {
804 let key = format!("ns/repo{n}");
805 state.jobs.insert(key.clone(), finished(&key, n as i64));
806 }
807 prune::<Nothing>(&mut state);
808 assert_eq!(state.jobs.len(), Nothing::FINISHED_KEPT);
809 assert!(
810 state
811 .jobs
812 .contains_key(&format!("ns/repo{}", Nothing::FINISHED_KEPT + 4)),
813 "the newest finished job must survive"
814 );
815 assert!(
816 !state.jobs.contains_key("ns/repo0"),
817 "the oldest finished job must be dropped"
818 );
819 }
820
821 /// A live job is never pruned, however many finished ones crowd it out.
822 #[test]
823 fn prune_never_drops_a_live_job() {
824 let mut state = State::<Nothing>::default();
825 let mut running = finished("ns/running", 0);
826 running.state = JobState::Running;
827 running.finished_at_unix_ms = 0;
828 state.jobs.insert("ns/running".into(), running);
829 for n in 0..(Nothing::FINISHED_KEPT + 5) {
830 let key = format!("ns/repo{n}");
831 state.jobs.insert(key.clone(), finished(&key, n as i64));
832 }
833 prune::<Nothing>(&mut state);
834 assert!(
835 state.jobs.contains_key("ns/running"),
836 "a running job must survive a prune; forgetting it would leave a \
837 console button lit for ever"
838 );
839 }
840
841 /// A panic carrying a `String` and one carrying a `&'static str` both come
842 /// back as their message. Asserted because the fallback text is what an
843 /// operator would otherwise read instead of the cause.
844 #[test]
845 fn a_panics_message_survives_being_caught() {
846 let owned: Box<dyn std::any::Any + Send> = Box::new("a static message".to_string());
847 assert_eq!(panic_text(&owned), "a static message");
848 let borrowed: Box<dyn std::any::Any + Send> = Box::new("a literal message");
849 assert_eq!(panic_text(&borrowed), "a literal message");
850 let odd: Box<dyn std::any::Any + Send> = Box::new(7u32);
851 assert_eq!(panic_text(&odd), "a panic with no message");
852 }
853}