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}