Skip to main content

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}