Skip to main content

notedthat_api_http/
readiness.rs

1//! What `/readyz` reports about the backends it depends on.
2//!
3//! The server probes the storage backend and the vector store in the
4//! background and publishes a [`ReadinessSnapshot`] through a
5//! [`tokio::sync::watch`] channel; the route reads the latest value and never
6//! probes anything itself, so a flood of readiness requests costs the backends
7//! nothing. The event backend is not part of the snapshot: its readiness is a
8//! connection-state read the route makes inline (D55).
9//!
10//! A backend's own error message never reaches the response. The route is
11//! unauthenticated, and a client error can quote an endpoint or a
12//! credential-bearing URL, so a check reports one of a fixed set of
13//! [`Unready`] reasons and the server logs the detail once per transition.
14//!
15//! Readiness is about the backend, not the data in it: a probe that the
16//! backend *answered* with "not found" leaves the replica ready, because it is
17//! up and every other knowledge base keeps serving. The check still says so
18//! (`degraded`, `not_found`), for the operator who deleted a bucket.
19
20use serde::Serialize;
21
22/// Why a check is not `ok`. Closed set: the response body can say nothing
23/// else about a failure.
24#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
25#[serde(rename_all = "snake_case")]
26pub enum Unready {
27    /// The probe did not answer within its deadline.
28    Timeout,
29    /// The backend answered with an error, or could not be reached at all.
30    Unreachable,
31    /// The backend answered, and the thing probed for is gone: a knowledge
32    /// base's bucket or directory deleted after startup. Reported, but not a
33    /// readiness failure — the backend is up.
34    NotFound,
35}
36
37impl Unready {
38    /// Whether this reason means the backend itself is unavailable, as opposed
39    /// to answering that the probed thing is gone.
40    #[must_use]
41    pub fn is_outage(self) -> bool {
42        match self {
43            Self::Timeout | Self::Unreachable => true,
44            Self::NotFound => false,
45        }
46    }
47
48    /// The `reason` value the route renders.
49    #[must_use]
50    pub fn as_str(self) -> &'static str {
51        match self {
52            Self::Timeout => "timeout",
53            Self::Unreachable => "unreachable",
54            Self::NotFound => "not_found",
55        }
56    }
57}
58
59/// One backend's latest probe.
60#[derive(Clone, Debug, PartialEq, Eq)]
61pub struct Check {
62    /// The selector value the backend answers to (`s3`, `fs`, `qdrant`), so a
63    /// reader can tell which deployment choice is failing.
64    pub backend: &'static str,
65    /// `Ok` when the last probe succeeded.
66    pub outcome: Result<(), Unready>,
67}
68
69impl Check {
70    /// A check whose last probe succeeded.
71    #[must_use]
72    pub fn ok(backend: &'static str) -> Self {
73        Self {
74            backend,
75            outcome: Ok(()),
76        }
77    }
78
79    /// A check whose last probe failed for `reason`.
80    #[must_use]
81    pub fn unready(backend: &'static str, reason: Unready) -> Self {
82        Self {
83            backend,
84            outcome: Err(reason),
85        }
86    }
87
88    /// Whether this check leaves the replica ready: the last probe succeeded,
89    /// or failed in a way that is not the backend's outage.
90    #[must_use]
91    pub fn is_ready(&self) -> bool {
92        match self.outcome {
93            Ok(()) => true,
94            Err(reason) => !reason.is_outage(),
95        }
96    }
97
98    /// Whether the last probe answered but found the probed thing gone: the
99    /// replica stays ready, and the body says so at the top as well.
100    #[must_use]
101    pub fn is_degraded(&self) -> bool {
102        matches!(self.outcome, Err(reason) if !reason.is_outage())
103    }
104
105    /// The `{"backend", "status", "reason"?}` object the route renders:
106    /// `ok`, `degraded` (answered, but the probed thing is gone) or
107    /// `unavailable`.
108    #[must_use]
109    pub fn to_json(&self) -> serde_json::Value {
110        match self.outcome {
111            Ok(()) => serde_json::json!({ "backend": self.backend, "status": "ok" }),
112            Err(reason) => serde_json::json!({
113                "backend": self.backend,
114                "status": if reason.is_outage() { "unavailable" } else { "degraded" },
115                "reason": reason.as_str(),
116            }),
117        }
118    }
119}
120
121/// The latest probe of every backend the poller covers.
122#[derive(Clone, Debug, PartialEq, Eq)]
123pub struct ReadinessSnapshot {
124    /// The object store: `s3` or `fs`.
125    pub storage: Check,
126    /// The vector store behind search and indexing.
127    pub search: Check,
128}
129
130impl ReadinessSnapshot {
131    /// Every check ok — what the poller publishes before its first probe,
132    /// since startup provisioning has just reached both backends.
133    #[must_use]
134    pub fn ok(storage_backend: &'static str, search_backend: &'static str) -> Self {
135        Self {
136            storage: Check::ok(storage_backend),
137            search: Check::ok(search_backend),
138        }
139    }
140
141    /// Whether every check leaves the replica ready (see [`Check::is_ready`]).
142    #[must_use]
143    pub fn is_ready(&self) -> bool {
144        self.storage.is_ready() && self.search.is_ready()
145    }
146
147    /// Whether any check is degraded (see [`Check::is_degraded`]).
148    #[must_use]
149    pub fn is_degraded(&self) -> bool {
150        self.storage.is_degraded() || self.search.is_degraded()
151    }
152}
153
154/// The route's end of the channel the poller publishes on.
155///
156/// `borrow()` keeps answering with the last value after the sender is gone,
157/// so a test can hand a state a fixed snapshot without running a poller.
158pub type ReadinessReceiver = tokio::sync::watch::Receiver<ReadinessSnapshot>;
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn an_ok_check_renders_without_a_reason() {
166        assert_eq!(
167            Check::ok("s3").to_json(),
168            serde_json::json!({ "backend": "s3", "status": "ok" })
169        );
170    }
171
172    #[test]
173    fn an_unready_check_renders_its_reason_and_nothing_else() {
174        let json = Check::unready("qdrant", Unready::Timeout).to_json();
175        assert_eq!(
176            json,
177            serde_json::json!({ "backend": "qdrant", "status": "unavailable", "reason": "timeout" })
178        );
179    }
180
181    #[test]
182    fn reasons_are_the_documented_vocabulary() {
183        for (reason, expected) in [
184            (Unready::Timeout, "timeout"),
185            (Unready::Unreachable, "unreachable"),
186            (Unready::NotFound, "not_found"),
187        ] {
188            assert_eq!(reason.as_str(), expected);
189            assert_eq!(serde_json::to_value(reason).unwrap(), expected);
190        }
191    }
192
193    #[test]
194    fn a_not_found_check_is_degraded_and_still_ready() {
195        let check = Check::unready("fs", Unready::NotFound);
196        assert!(check.is_ready());
197        assert!(check.is_degraded());
198        assert!(!Check::ok("fs").is_degraded());
199        assert!(!Check::unready("fs", Unready::Timeout).is_degraded());
200        assert_eq!(
201            check.to_json(),
202            serde_json::json!({ "backend": "fs", "status": "degraded", "reason": "not_found" })
203        );
204    }
205
206    #[test]
207    fn ready_unless_a_backend_is_out() {
208        assert!(ReadinessSnapshot::ok("fs", "qdrant").is_ready());
209        let witness_gone = ReadinessSnapshot {
210            storage: Check::unready("fs", Unready::NotFound),
211            search: Check::ok("qdrant"),
212        };
213        assert!(witness_gone.is_ready(), "the backend answered; it is up");
214        assert!(
215            witness_gone.is_degraded(),
216            "and the body says so at the top"
217        );
218        assert!(!ReadinessSnapshot::ok("fs", "qdrant").is_degraded());
219        for reason in [Unready::Timeout, Unready::Unreachable] {
220            let storage_down = ReadinessSnapshot {
221                storage: Check::unready("s3", reason),
222                search: Check::ok("qdrant"),
223            };
224            assert!(!storage_down.is_ready());
225            let search_down = ReadinessSnapshot {
226                storage: Check::ok("fs"),
227                search: Check::unready("qdrant", reason),
228            };
229            assert!(!search_down.is_ready());
230        }
231    }
232}