feather_reader/runtime_health.rs
1//! What the background loops are actually doing, readable from a request.
2//!
3//! The two states that stop feeds from updating — the DB-size watermark pause
4//! and a poller that has stopped ticking — were previously observable only as
5//! log lines. `/stats` publishes `overdue` and `polled_last_hour`, which move in
6//! BOTH states and distinguish neither, and `/health` returned a constant
7//! string. So every degraded-but-running instance presented as a green machine,
8//! and "my feeds stopped updating" left the operator with `fly logs` and nothing
9//! else.
10//!
11//! This is deliberately **process-local and lossy**: a few atomics, no
12//! persistence, no history. It answers "what is the loop doing right now",
13//! which is the question an operator has in the middle of an incident. Anything
14//! that needs to survive a restart already lives in SQLite (`feeds.next_poll`,
15//! `feeds.consecutive_errors`), and is read from there.
16//!
17//! Everything here is a **machine fact** — no user counts, no DIDs, no feed
18//! URLs — so it can be published on the same terms as `/stats`. Note the actual
19//! constraint is tighter than that: `/stats` sits behind the Cloudflare origin
20//! lock, while `/health` is the ONE path exempt from it, and `/health` is where
21//! most of this surfaces.
22
23use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
24use std::sync::Mutex;
25
26/// Shared record of background-loop state. Cheap to read from a handler.
27#[derive(Debug, Default)]
28pub struct RuntimeHealth {
29 /// Unix seconds when the poll loop last COMPLETED a tick. `0` = never.
30 ///
31 /// Completed, not started: a tick that begins and then hangs must not keep
32 /// the heartbeat looking fresh, since a hung poller is exactly the condition
33 /// this exists to surface.
34 last_poll_tick: AtomicI64,
35 /// Whether the DB-size watermark is currently pausing new fetches.
36 watermark_paused: AtomicBool,
37 /// Whether the background loops were started at all
38 /// (`FEATHERREADER_DISABLE_SCHEDULER` turns them off for dev and tests).
39 ///
40 /// Without this, "the poller has never ticked" and "the poller was never
41 /// started" look identical from a handler — and they call for completely
42 /// different responses.
43 schedulers_enabled: AtomicBool,
44 /// Unix seconds when this process started. `0` until stamped.
45 ///
46 /// Under a supervisor where ANY child exit tears the container down, "how
47 /// long has this process been alive" is the single most diagnostic number
48 /// about the system, and nothing exposed it: a crash-looping instance and a
49 /// healthy one both answered every question identically. It is also what
50 /// makes "the poller has not ticked yet" interpretable — benign twenty
51 /// seconds after boot, and a dead poller twenty minutes after it.
52 started_at: AtomicI64,
53 /// The most recent database-probe verdict, reused by requests that arrive
54 /// while another probe is already running.
55 db_probe: Mutex<Option<DbProbe>>,
56 /// Whether a database probe is in flight right now.
57 db_probe_running: AtomicBool,
58}
59
60impl RuntimeHealth {
61 pub fn new() -> Self {
62 Self::default()
63 }
64
65 /// Stamp the process start time. Called once, from `main`.
66 pub fn set_started_at(&self, now_unix: i64) {
67 self.started_at.store(now_unix, Ordering::Relaxed);
68 }
69
70 /// Seconds this process has been alive, or `None` before it was stamped.
71 pub fn uptime_secs(&self, now_unix: i64) -> Option<i64> {
72 match self.started_at.load(Ordering::Relaxed) {
73 0 => None,
74 t => Some((now_unix - t).max(0)),
75 }
76 }
77
78 /// Claim the right to run a database probe, or borrow the last verdict.
79 ///
80 /// `/health` is exempt from the Cloudflare origin lock and is not in the rate
81 /// limiter's path list, so it answers unauthenticated requests at whatever
82 /// rate they arrive. Once it started touching the database, that became a way
83 /// to spend the 5-connection pool from outside — and the handler's own
84 /// timeout then returns the 503 that Fly's check reads.
85 ///
86 /// This deduplicates CONCURRENT probes rather than caching by time. A time
87 /// cache would also bound the cost, but it makes a genuinely dead database
88 /// invisible for the length of the window; this bounds in-flight database
89 /// work to exactly one probe while leaving every sequential request — Fly's
90 /// own, and any operator's `curl` — a fresh answer. It is also a better fix
91 /// than rate-limiting `/health`, which would risk refusing Fly's probe.
92 ///
93 /// Returns `Err(verdict)` when a probe is already running: the caller reports
94 /// that instead of starting another. `Ok(guard)` means the caller owns the
95 /// probe and must report it via the returned guard.
96 pub fn begin_db_probe(self: &std::sync::Arc<Self>) -> Result<DbProbeGuard, DbProbe> {
97 if self.db_probe_running.swap(true, Ordering::AcqRel) {
98 // Someone else is probing. Borrow their last answer — or say we have
99 // none, which is a THIRD state and not a synonym for either.
100 //
101 // **This has now been wrong in both directions, and the enum was
102 // never the bug.** It first answered "healthy" for a database nothing
103 // had read. Correcting that to a failure made it worse: `/health` is
104 // outside both the origin lock and the rate limiter, so an
105 // unauthenticated caller could manufacture the no-verdict state and
106 // make Fly's own check read a failure — deregistering the only
107 // machine. Making that state return 200 again fixed the severe
108 // direction and left the mild one: a caller who keeps the claim
109 // occupied freezes the published verdict.
110 //
111 // The ROOT CAUSE was that the claim could be released without a
112 // verdict, which a client disconnect was enough to cause. The probe
113 // now runs in a spawned task that completes regardless of whether the
114 // request that started it survives, so the claim is released only
115 // AFTER a verdict is recorded. `Unknown` is consequently reachable
116 // only before the first probe of a process completes — a real,
117 // brief, un-manufacturable state.
118 //
119 // `Unknown` does not fail the check, because only a MEASURED failure
120 // is evidence of one — but it does not report `ok` either. See
121 // `web::health`.
122 return Err(self.last_db_probe().unwrap_or(DbProbe::Unknown));
123 }
124 Ok(DbProbeGuard {
125 health: std::sync::Arc::clone(self),
126 })
127 }
128
129 /// Publish a verdict WITHOUT owning the probe claim. Test-only: the
130 /// production path always records through [`DbProbeGuard::record`], which
131 /// consumes the guard so the claim cannot be released before the verdict is
132 /// written.
133 #[cfg(test)]
134 pub fn record_for_test(&self, verdict: DbProbe) {
135 *self
136 .db_probe
137 .lock()
138 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(verdict);
139 }
140
141 /// The most recent verdict, if any probe has completed.
142 fn last_db_probe(&self) -> Option<DbProbe> {
143 self.db_probe
144 .lock()
145 .unwrap_or_else(std::sync::PoisonError::into_inner)
146 .clone()
147 }
148
149 /// Record that the background loops were (or were not) spawned.
150 pub fn set_schedulers_enabled(&self, enabled: bool) {
151 self.schedulers_enabled.store(enabled, Ordering::Relaxed);
152 }
153
154 pub fn schedulers_enabled(&self) -> bool {
155 self.schedulers_enabled.load(Ordering::Relaxed)
156 }
157
158 /// Stamp a completed poll tick at `now_unix`.
159 pub fn poll_tick_completed(&self, now_unix: i64) {
160 self.last_poll_tick.store(now_unix, Ordering::Relaxed);
161 }
162
163 /// Seconds since the last completed poll tick, or `None` if there has not
164 /// been one yet.
165 pub fn secs_since_poll_tick(&self, now_unix: i64) -> Option<i64> {
166 match self.last_poll_tick.load(Ordering::Relaxed) {
167 0 => None,
168 // Never negative: a clock step backwards reads as "just now" rather
169 // than as a negative age, matching `store::secs_between`.
170 t => Some((now_unix - t).max(0)),
171 }
172 }
173
174 /// Record the outcome of a watermark check.
175 ///
176 /// Deliberately just the verdict, not the measured size. The size is already
177 /// in the log line that accompanies a pause, and the two surfaces that read
178 /// this — `/health` and `/stats` — are both reachable without the Cloudflare
179 /// origin lock or a session, so neither publishes precise internal numbers.
180 pub fn set_watermark(&self, paused: bool) {
181 self.watermark_paused.store(paused, Ordering::Relaxed);
182 }
183
184 /// Whether the watermark is currently pausing new fetches. This is the
185 /// state in which `polled_last_hour` falls and `overdue` climbs for a reason
186 /// that has nothing to do with the poller being too slow.
187 pub fn watermark_paused(&self) -> bool {
188 self.watermark_paused.load(Ordering::Relaxed)
189 }
190}
191
192/// Held by whichever request owns the in-flight database probe. Recording the
193/// verdict — or being dropped without one — releases the claim, so a panicking
194/// or cancelled handler cannot wedge every later probe.
195/// Owns an `Arc` rather than borrowing, so the probe can be moved into a
196/// spawned task and survive the request that started it. See
197/// [`RuntimeHealth::begin_db_probe`] for why that matters.
198pub struct DbProbeGuard {
199 health: std::sync::Arc<RuntimeHealth>,
200}
201
202/// The outcome of a database probe, as `/health` reports it.
203///
204/// Three states, deliberately — "we have not measured yet" is not a synonym for
205/// either "fine" or "broken", and collapsing it into one of them has been a bug
206/// in both directions. Only [`DbProbe::Failed`] may fail the health check.
207#[derive(Debug, Clone, PartialEq, Eq)]
208pub enum DbProbe {
209 /// A probe completed and read a page.
210 Ok,
211 /// A probe completed and could not. The string is a COARSE reason — the
212 /// detail goes to the log, because this body answers unauthenticated
213 /// callers.
214 Failed(String),
215 /// No probe has completed yet. Reported, never fatal.
216 Unknown,
217}
218
219impl DbProbeGuard {
220 /// Publish the verdict this probe reached.
221 pub fn record(self, verdict: DbProbe) {
222 *self
223 .health
224 .db_probe
225 .lock()
226 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(verdict);
227 }
228}
229
230impl Drop for DbProbeGuard {
231 fn drop(&mut self) {
232 self.health.db_probe_running.store(false, Ordering::Release);
233 }
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239
240 #[test]
241 fn a_fresh_record_reports_nothing_rather_than_zero() {
242 let h = RuntimeHealth::new();
243 // "Never ticked" must not read as "ticked 0 seconds ago" — on a machine
244 // that has just booted, the latter is the healthiest possible answer to
245 // a question nothing has answered yet.
246 assert_eq!(h.secs_since_poll_tick(1_000), None);
247 assert!(!h.watermark_paused());
248 assert!(!h.schedulers_enabled());
249 }
250
251 #[test]
252 fn a_tick_ages_and_a_backwards_clock_does_not_go_negative() {
253 let h = RuntimeHealth::new();
254 h.poll_tick_completed(1_000);
255 assert_eq!(h.secs_since_poll_tick(1_090), Some(90));
256 assert_eq!(
257 h.secs_since_poll_tick(900),
258 Some(0),
259 "a clock step backwards must read as 'just now', not as a negative age"
260 );
261 }
262
263 #[test]
264 fn the_watermark_verdict_round_trips_both_ways() {
265 let h = RuntimeHealth::new();
266 h.set_watermark(true);
267 assert!(h.watermark_paused());
268 // And it must clear again — a pause that latched would keep every page
269 // claiming an outage long after the retention sweep freed the space.
270 h.set_watermark(false);
271 assert!(!h.watermark_paused());
272 }
273}