gunnar_server/purge_queue.rs
1//! **One account purge at a time, and the lockout always comes off.**
2//!
3//! `Accounts.Purge` empties a user's whole account: every repository, every
4//! soft-delete tombstone still holding bytes, every orphan store under the
5//! account's directory subtree, and the namespace rows themselves. That is
6//! minutes of pure unlink I/O on a large account, so it is queued rather than
7//! run inline, for exactly the reason [`crate::gc_queue`] gives for a gc.
8//!
9//! # Why this is a SECOND LINE and not more jobs on the gc one
10//!
11//! The mechanism is shared — both kinds run on [`crate::job_queue`], which is
12//! one worker, one FIFO, dedup by subject and a finished job kept for a poller.
13//! What is **not** shared is the line, and that is the ruling this module has
14//! always argued for. It survives the extraction unchanged:
15//!
16//! **A purge holds a total lockout on a customer's account for the whole time
17//! it is queued.** A gc holds nothing — it is maintenance, it is re-derivable,
18//! and it can wait behind anything without costing anybody anything. Put them
19//! in one queue and a user is locked out of their account while the box works
20//! through an hour of somebody else's repository maintenance, because a gc that
21//! arrived first is ahead of them. The queue a job waits in is part of what the
22//! job costs, and these two jobs cost different things while waiting.
23//!
24//! So `CoreControlPlane` holds two [`crate::job_queue::JobQueue`]s and each has
25//! its own jera lane. What used to be two copies of the queue is now two
26//! instances of one, which is the same two lines and half the code.
27//!
28//! # The property this module exists for
29//!
30//! The gate goes on **before** the job is enqueued, in the RPC handler, so
31//! there is no window in which a purge is accepted and the user can still push.
32//! **That is deliberately not the queue's business** — the queue cannot promise
33//! it, because the promise is about what is true when `Accounts.Purge`
34//! *returns*, which is before the queue has done anything at all. The gate is
35//! taken by the caller, under an id the caller takes from
36//! [`PurgeQueue::next_job_id`] first.
37//!
38//! It comes off **here**, in [`JobKind::settle`], and it comes off on **every**
39//! path out of the runner:
40//!
41//! * the runner returns `Ok` → released;
42//! * the runner returns `Err` → released, and the job is FAILED with the
43//! reason and the list of stores still on the disk;
44//! * the runner **panics** → caught, released, the job is FAILED saying so,
45//! **and the worker survives**. An unwinding worker takes the queue with it,
46//! so the account after the panicking one would sit QUEUED for ever behind a
47//! thread that no longer exists — one account's bug bricking the next one's.
48//!
49//! The catch and the always-runs settle are now [`crate::job_queue`]'s, which
50//! is the whole gain from the extraction: a gc, and a mirror, and anything
51//! after them get the same two guarantees without a second person having to
52//! rediscover why they matter.
53//!
54//! What this module cannot cover is the process dying between two unlinks.
55//! That is what the lease in `gunnar_core::gate` is for: a purge's gate carries
56//! a deadline, the runner re-stamps it as it goes, and a gate whose deadline
57//! has passed is not a gate. Belt here, braces there, because the outcome they
58//! both prevent — an account shut by a row nobody remembers, held by a process
59//! that is gone — is worse than an unfinished purge. The boot sweep that
60//! releases every lease a killed process left behind is
61//! `CoreControlPlane::release_stale_purge_leases`, called once at start-up: it
62//! is about rows in the identity table and not about jobs, so it is not the
63//! queue's and did not move here.
64//!
65//! # What this module is not
66//!
67//! It is not the purge. That is [`crate::admin::purge_account`], reached
68//! through a runner handed in at construction, so the queue can be tested with
69//! no disk under it and there is one implementation of what emptying an account
70//! means (one implementation). The release is handed in the same way and is
71//! `AuthTable::lift_gate_held_by` in the real server — *held_by*, so a purge
72//! finishing cannot lift a suspension an operator put on while it ran.
73
74use std::sync::Arc;
75use std::time::Duration;
76
77use crate::job_queue::{Job, JobKind, JobQueue, JobState, Reporter};
78
79/// How long a finished purge stays readable. The same ten minutes
80/// [`crate::gc_queue::FINISHED_RETENTION`] uses, and for the same reason: far
81/// longer than any console poll, far shorter than a session.
82///
83/// Its own constant rather than a re-export, because the two queues holding the
84/// same number today is a coincidence and not a contract — a purge's result is
85/// the more valuable of the two to still have when somebody looks back. The
86/// runner takes it per kind ([`JobKind::FINISHED_RETENTION`]) for that reason.
87pub const FINISHED_RETENTION: Duration = <Purge as JobKind>::FINISHED_RETENTION;
88
89/// Finished jobs kept regardless of age, most recent first.
90pub const FINISHED_KEPT: usize = <Purge as JobKind>::FINISHED_KEPT;
91
92/// Where a purge is. [`JobState`]'s four words, shared with every other kind of
93/// job so `PurgeStatus` and `GcStatus` cannot drift apart about what `Done`
94/// means.
95pub type PurgeState = JobState;
96
97/// One account purge, running or completed.
98///
99/// A [`Job`] of kind [`Purge`]: the queue's fields (`job_id`, `state`,
100/// `queue_position`, `error`, the timestamps) plus a purge's [`PurgeReport`],
101/// which dereferences flat — `job.stores_purged` reads the report and
102/// `job.state` the queue. The account is `job.key`.
103pub type PurgeJob = Job<Purge>;
104
105/// What a purge found, reclaimed, and gave back.
106#[derive(Debug, Clone, Default)]
107pub struct PurgeReport {
108 /// Stores the walk found. **0 until the job starts.**
109 pub stores_found: u32,
110 pub stores_purged: u32,
111 pub bytes_reclaimed: u64,
112 pub principal_removed: bool,
113 /// **Whether the lockout this job took has been released**, as observed —
114 /// set from what the release closure actually answered, never optimistically.
115 pub gate_released: bool,
116 /// One line per store that could not be reclaimed. The recovery list.
117 pub failures: Vec<String>,
118}
119
120/// **What a purge was asked for**, carried on the job so the worker can hand it
121/// back to the runner minutes later.
122///
123/// The queue holds the whole order rather than the account name alone because
124/// `remove_principal` and `expect_empty` change what a purge *is*, and a queue
125/// that kept them in a side map keyed by account would be a second place the
126/// two could disagree — with the more destructive reading winning whenever they
127/// did.
128#[derive(Debug, Clone)]
129pub struct PurgeOrder {
130 /// The account: a principal id, which is also its namespace.
131 pub account: String,
132 /// **The job id, minted before the gate was taken**, because the gate's
133 /// holder has to be this job or the release could not tell this lockout
134 /// from anybody else's. See [`PurgeQueue::next_job_id`].
135 pub job_id: String,
136 /// The principal that asked, for the audit trail.
137 pub principal_id: String,
138 /// Why, shown to the locked-out user and written into every reclaim's log
139 /// line.
140 pub reason: String,
141 /// Also remove the principal, its keys, grants, tokens and memberships.
142 pub remove_principal: bool,
143 /// Refuse rather than destroy unless the account holds no live
144 /// repositories.
145 pub expect_empty: bool,
146}
147
148impl PurgeOrder {
149 /// The ordinary order: empty the account, keep the principal.
150 pub fn new(
151 account: impl Into<String>,
152 job_id: impl Into<String>,
153 reason: impl Into<String>,
154 ) -> Self {
155 PurgeOrder {
156 account: account.into(),
157 job_id: job_id.into(),
158 principal_id: String::new(),
159 reason: reason.into(),
160 remove_principal: false,
161 expect_empty: false,
162 }
163 }
164
165 /// Record who asked.
166 pub fn by(mut self, principal_id: impl Into<String>) -> Self {
167 self.principal_id = principal_id.into();
168 self
169 }
170
171 /// Take the principal with the data.
172 pub fn removing_principal(mut self, remove: bool) -> Self {
173 self.remove_principal = remove;
174 self
175 }
176
177 /// Refuse rather than destroy if anything is still live.
178 pub fn expecting_empty(mut self, expect: bool) -> Self {
179 self.expect_empty = expect;
180 self
181 }
182}
183
184/// **What a purge actually is**, handed in so this module does not have to know.
185///
186/// Takes the order and a [`Progress`] to report through, and returns `Ok` when
187/// the account is empty. The real one is
188/// [`crate::account_purge::purge_account`].
189///
190/// It reports through a handle rather than returning a summary because a purge
191/// of a large account runs for minutes and a console is polling the whole time:
192/// a return value can only be read once the walk is over, which is precisely
193/// when nobody needs it any more.
194pub type PurgeRunner = Arc<dyn Fn(&PurgeOrder, &Progress) -> Result<(), String> + Send + Sync>;
195
196/// **How the lockout this job took is released.**
197///
198/// `(account, job_id) -> Ok(true)` when this job's gate was lifted, `Ok(false)`
199/// when there was nothing of this job's to lift — an operator replaced the row
200/// while the purge ran, or the lease expired and something else took it. The
201/// real one is `AuthTable::lift_gate_held_by`.
202///
203/// `Ok(false)` is **not** a failure: the account is shut on somebody else's
204/// authority, which is a correct outcome. `Err` is, and it is the loudest
205/// state this queue can reach — the account is empty AND locked.
206pub type GateRelease = Arc<dyn Fn(&str, &str) -> Result<bool, String> + Send + Sync>;
207
208/// The handle a running purge reports through.
209///
210/// The shared runner's [`Reporter`], with a purge's vocabulary put on it: a
211/// walk says `found`, `reclaimed`, `failed`, not "edit field 3 of the report".
212/// Every method takes `&self` and writes into the job under the queue's lock,
213/// so the console polling `PurgeStatus` sees a count that moves rather than a
214/// job that sits at zero for four minutes and then jumps.
215pub type Progress = Reporter<Purge>;
216
217impl Progress {
218 /// **How many stores the walk found.** Called once, when the walk has
219 /// finished counting and before it starts unlinking.
220 pub fn found(&self, stores: u32) {
221 self.report(|job| job.stores_found = stores);
222 }
223
224 /// One store is gone, and this is what it was holding.
225 pub fn reclaimed(&self, bytes: u64) {
226 self.report(|job| {
227 job.stores_purged = job.stores_purged.saturating_add(1);
228 job.bytes_reclaimed = job.bytes_reclaimed.saturating_add(bytes);
229 });
230 }
231
232 /// One store could NOT be reclaimed, and this is why.
233 ///
234 /// Recorded and the walk continues. Stopping at the first failure would
235 /// leave the rest of an account that is being closed on the disk because
236 /// one directory had bad permissions — and the operator would have to run
237 /// the purge again anyway.
238 pub fn failed(&self, note: String) {
239 self.report(|job| job.failures.push(note));
240 }
241
242 /// The principal itself was removed.
243 pub fn principal_removed(&self) {
244 self.report(|job| job.principal_removed = true);
245 }
246
247 /// The account this purge is emptying, for a runner that wants it back.
248 pub fn account(&self) -> &str {
249 self.key()
250 }
251}
252
253/// **The purge kind.** What a purge is, and — the part a gc has no equivalent
254/// of — what a purge has to give back when it ends, however it ends.
255pub struct Purge {
256 run: PurgeRunner,
257 release: GateRelease,
258}
259
260impl JobKind for Purge {
261 const NAME: &'static str = "purge";
262
263 type Order = PurgeOrder;
264 type Report = PurgeReport;
265
266 /// Keyed by ACCOUNT, because both questions the queue answers are per
267 /// account: "is one already running for this account" on enqueue, and "how
268 /// is mine going" on status.
269 fn key(order: &PurgeOrder) -> &str {
270 &order.account
271 }
272
273 fn run(&self, order: &PurgeOrder, progress: &Progress) -> Result<(), String> {
274 (self.run)(order, progress)
275 }
276
277 /// **The release, on every path.**
278 ///
279 /// Unconditional, and the runner reaches it after a success, after a
280 /// failure and after a panic — that is what [`JobKind::settle`] is for and
281 /// it is the whole reason the release is the queue's job and not the
282 /// runner's: a runner that returned early, failed, or blew up would each be
283 /// a different missed unlock, and "release it in the runner's error path
284 /// too" is a rule that holds until the next early return somebody adds.
285 fn settle(
286 &self,
287 order: &PurgeOrder,
288 outcome: Result<(), String>,
289 progress: &Progress,
290 ) -> (JobState, Option<String>) {
291 let released = (self.release)(&order.account, &order.job_id);
292 // What the closure ACTUALLY answered, never optimism: an `Err` is a
293 // lockout still on, and a job claiming otherwise would send an operator
294 // away from the one account that needs them.
295 let lifted = matches!(released, Ok(true));
296 progress.report(|job| job.gate_released = lifted);
297 match (outcome, released) {
298 // The ordinary end: emptied, and unlocked (or left locked by
299 // whoever else holds the row, which is `Ok(false)` and is not this
300 // job's failure).
301 (Ok(()), Ok(_)) => (JobState::Done, None),
302 // **Emptied but still locked.** The single worst outcome this queue
303 // can produce and it must never read as success: the customer's
304 // data is gone AND they cannot use what is left of the account.
305 (Ok(()), Err(why)) => (
306 JobState::Failed,
307 Some(format!(
308 "the account was emptied but the lockout could NOT be lifted: {why}. The \
309 account is unusable until the gate is lifted by hand"
310 )),
311 ),
312 (Err(why), Ok(_)) => (JobState::Failed, Some(why)),
313 (Err(why), Err(unlock)) => (
314 JobState::Failed,
315 Some(format!(
316 "{why}; and the lockout could NOT be lifted either: {unlock}. The account is \
317 unusable until the gate is lifted by hand"
318 )),
319 ),
320 }
321 }
322
323 /// Reported with `gate_released: false` on purpose: the caller has already
324 /// taken a lockout under this id and has to know that nothing is going to
325 /// take it off.
326 fn shutdown_error(&self) -> String {
327 "the purge queue is shutting down; the account is LOCKED and nothing here will unlock \
328 it — lift the gate by hand"
329 .to_owned()
330 }
331
332 /// No worker means the job will never run — and the account is already
333 /// locked. Say both, on the job, rather than leave it QUEUED for ever.
334 fn spawn_error(&self, err: &dyn std::fmt::Display) -> String {
335 format!(
336 "the purge worker could not be started: {err}. The account is LOCKED and nothing \
337 here will unlock it — lift the gate by hand"
338 )
339 }
340}
341
342/// The queue: one worker, one purge at a time, FIFO, deduplicated by account.
343///
344/// A named type over [`JobQueue<Purge>`] rather than an alias, so
345/// `Accounts.Purge`'s door keeps taking a [`PurgeOrder`] and taking its job id
346/// out first, which is the shape the gate demands.
347#[derive(Clone)]
348pub struct PurgeQueue(JobQueue<Purge>);
349
350impl PurgeQueue {
351 /// A queue over `run`, releasing gates through `release`. No thread is
352 /// started until something is enqueued.
353 pub fn new(run: PurgeRunner, release: GateRelease) -> Self {
354 PurgeQueue(JobQueue::new(Purge { run, release }))
355 }
356
357 /// **The job id the NEXT enqueue would use**, without enqueuing.
358 ///
359 /// The gate has to be taken before the job exists — that is what makes the
360 /// lockout a fact by the time `Accounts.Purge` returns — and the gate's
361 /// `holder` has to be the job id, or the release could not tell this job's
362 /// lockout from anybody else's. So the id is minted here, handed to the
363 /// gate, and handed back in on [`PurgeQueue::enqueue`] inside the order.
364 ///
365 /// Minting from the same counter as `enqueue`, so an id handed out here and
366 /// never used simply leaves a gap. A gap in a job-id sequence costs
367 /// nothing; two jobs sharing an id would make the release ambiguous.
368 pub fn next_job_id(&self) -> String {
369 self.0.next_job_id()
370 }
371
372 /// **Ask for an account to be emptied.**
373 ///
374 /// Returns the job now responsible for the account: a new one, or the one
375 /// that was already queued or running — in which case this order's
376 /// `job_id` is *not* used, and the caller must not assume its gate is the
377 /// one that will be released. `Accounts.Purge` handles that by reading the
378 /// gate back out of the table rather than trusting the one it wrote.
379 pub fn enqueue(&self, order: PurgeOrder) -> PurgeJob {
380 let job_id = order.job_id.clone();
381 self.0.enqueue_as(order, job_id)
382 }
383
384 /// The job the queue is holding for `account`, if it still has one.
385 pub fn status(&self, account: &str) -> Option<PurgeJob> {
386 self.0.status(account)
387 }
388
389 /// The highest number of purges that have ever run together. **Must be 1.**
390 pub fn peak_in_flight(&self) -> usize {
391 self.0.peak_in_flight()
392 }
393
394 /// How many times the runner has been called.
395 pub fn runs(&self) -> u64 {
396 self.0.runs()
397 }
398
399 /// Stop the worker after the job it is on.
400 pub fn stop(&self) {
401 self.0.stop()
402 }
403}