Skip to main content

didbot_serve/
stale_sweep.rs

1//! The timer that runs `didbot_pds::Provisioner::sweep_stale_bounded`.
2//!
3//! A process that stops between storing an account and activating it — a
4//! `SIGKILL`, a lost machine, an OOM kill — leaves a row behind that nothing
5//! else comes back for. The sweep removes that row, whatever the account's
6//! kind, once it is older than [`DEFAULT_UNFINISHED_AGE`], and its hostname
7//! answers 404 from then on the way every name no account holds does. It
8//! also finishes an erasure a crash left half done. Every other account
9//! stays, whatever its age.
10//!
11//! # Why a timer rather than a startup pass
12//!
13//! Both, really — the first tick fires immediately, so a restart reaps
14//! without waiting an interval, and the ticks after it cover a process that
15//! stays up for weeks. A startup-only pass would leave a long-lived server
16//! accumulating stale rows until somebody restarted it, which is the
17//! opposite of what a deployment that must not be restarted wants.
18//!
19//! # What one tick costs
20//!
21//! Each deletion takes the write lock every request path takes.
22//! [`SweepSchedule::limit`] is what keeps a deployment that has accumulated
23//! a backlog from holding it for one tick of thousands of deletions back to
24//! back; what a tick leaves behind is taken by the next one.
25
26use std::panic::AssertUnwindSafe;
27use std::sync::Arc;
28use std::time::Duration as StdDuration;
29
30use didbot_pds::StaleSweep;
31use tokio::sync::Notify;
32use tokio::task::JoinHandle;
33use tokio::time::MissedTickBehavior;
34use tracing::{info, warn};
35
36/// How often the sweep runs, absent configuration.
37///
38/// An hour. [`DEFAULT_UNFINISHED_AGE`] is days rather than minutes, so the
39/// interval is what bounds how long a stranded row stays past that window —
40/// an hour of it is well inside the noise of a window measured in days, and
41/// cheap enough to run on a server that is otherwise idle.
42pub const DEFAULT_SWEEP_INTERVAL: StdDuration = StdDuration::from_secs(60 * 60);
43
44/// How old a row that never became an account gets before the sweep
45/// removes it, absent configuration.
46///
47/// Seven days. Provisioning finishes in one call, so a row still `reserved`
48/// or `provisioning` a week later was left by a process that died.
49pub const DEFAULT_UNFINISHED_AGE: time::Duration = time::Duration::days(7);
50
51/// How many rows one tick takes, absent configuration.
52///
53/// Sized against the write lock each removal takes: a hundred removals is a
54/// sweep that finishes well inside a request's patience, and a backlog
55/// larger than that is drained an hour at a time.
56pub const DEFAULT_SWEEP_LIMIT: usize = 100;
57
58/// When the sweep runs and how much it takes.
59///
60/// The three together because they are one decision: how quickly this
61/// deployment reclaims what a killed process left behind, and how long it
62/// is willing to hold its own write lock doing so.
63#[derive(Debug, Clone, Copy)]
64pub struct SweepSchedule {
65    /// How often the sweep fires.
66    pub interval: StdDuration,
67    /// How old a row that never became an account gets before the sweep
68    /// removes it.
69    pub unfinished_age: time::Duration,
70    /// The most rows one tick removes.
71    pub limit: usize,
72}
73
74impl Default for SweepSchedule {
75    /// [`DEFAULT_SWEEP_INTERVAL`], [`DEFAULT_UNFINISHED_AGE`] and
76    /// [`DEFAULT_SWEEP_LIMIT`].
77    fn default() -> Self {
78        Self {
79            interval: DEFAULT_SWEEP_INTERVAL,
80            unfinished_age: DEFAULT_UNFINISHED_AGE,
81            limit: DEFAULT_SWEEP_LIMIT,
82        }
83    }
84}
85
86/// A running sweep, stopped by [`StaleSweeper::stop`] or by dropping it.
87pub struct StaleSweeper {
88    handle: JoinHandle<()>,
89    stop: Arc<Notify>,
90}
91
92impl StaleSweeper {
93    /// Starts sweeping `registry` on `schedule`.
94    ///
95    /// The first tick fires immediately, so a process that has just come back
96    /// from the crash that stranded an account starts reaping at once. That
97    /// pass runs alongside serving.
98    ///
99    /// The sweep itself runs on a blocking thread: it takes the same write
100    /// lock every write path takes, and a task doing that on a runtime worker
101    /// holds the worker for the duration.
102    /// That is also what makes a panicking sweep survivable — the panic
103    /// arrives here as a join error, is logged, and the next tick tries again
104    /// rather than the timer dying with it.
105    pub fn spawn(registry: Arc<dyn StaleSweep>, schedule: SweepSchedule) -> Self {
106        let stop = Arc::new(Notify::new());
107        let notified = stop.clone();
108        let handle = tokio::spawn(async move {
109            let mut ticker = tokio::time::interval(schedule.interval);
110            ticker.set_missed_tick_behavior(MissedTickBehavior::Delay);
111            loop {
112                tokio::select! {
113                    // Biased so a stop that arrives with a due tick wins,
114                    // rather than the shutdown waiting out one more sweep.
115                    biased;
116                    () = notified.notified() => break,
117                    _ = ticker.tick() => {}
118                }
119                sweep_once(registry.clone(), schedule).await;
120            }
121        });
122        Self { handle, stop }
123    }
124
125    /// Stops the sweep and waits for the task to finish.
126    pub async fn stop(mut self) {
127        self.stop.notify_one();
128        // Borrowed rather than moved: `Drop` below aborts the handle, and a
129        // type that owns a `Drop` cannot give its field away.
130        let _ = (&mut self.handle).await;
131    }
132}
133
134impl Drop for StaleSweeper {
135    fn drop(&mut self) {
136        self.handle.abort();
137    }
138}
139
140/// One tick: sweep, say what it took.
141///
142/// Public and separate from the timer so a caller — a test, or a deployment
143/// reaping once from somewhere else — can run exactly one pass without
144/// waiting on a clock. The spawned sweep calls nothing else.
145pub async fn sweep_once(registry: Arc<dyn StaleSweep>, schedule: SweepSchedule) {
146    let swept = tokio::task::spawn_blocking(move || {
147        // `catch_unwind` as well as the join error below: a panic inside a
148        // blocking task is already surfaced as a join error, and catching it
149        // here is what keeps the panic's own message attached to the sweep
150        // that raised it rather than to the timer that scheduled it.
151        std::panic::catch_unwind(AssertUnwindSafe(|| {
152            registry.sweep_stale_bounded(schedule.unfinished_age, schedule.limit)
153        }))
154    })
155    .await;
156
157    match swept {
158        Ok(Ok(swept)) if swept.is_empty() => {}
159        Ok(Ok(swept)) => info!(
160            rows = swept.len(),
161            limit = schedule.limit,
162            unfinished_age = %schedule.unfinished_age,
163            "swept stale rows"
164        ),
165        // Both arms leave the timer running: whatever refused this pass, the
166        // rows it was about to take are still there to take next hour, and a
167        // deployment whose sweep has stopped is one accumulating rows nobody
168        // finished.
169        Ok(Err(_)) => warn!("the stale-account sweep panicked; the next tick tries again"),
170        Err(error) => {
171            warn!(%error, "the stale-account sweep did not finish; the next tick tries again")
172        }
173    }
174}