Skip to main content

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}