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//!
20//! One check is not probed at all. On the `s3` backend the server asks each bucket
21//! once, at startup, whether it enforces the preconditions on a conditional `PUT`
22//! (D70); a deployment that chose to run on one that does not is reported
23//! `degraded` (`preconditions_not_enforced`) for as long as the process lives.
24
25use serde::Serialize;
26
27/// Why a check is not `ok`. Closed set: the response body can say nothing
28/// else about a failure.
29#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
30#[serde(rename_all = "snake_case")]
31pub enum Unready {
32    /// The probe did not answer within its deadline.
33    Timeout,
34    /// The backend answered with an error, or could not be reached at all.
35    Unreachable,
36    /// The backend answered, and the thing probed for is gone: a knowledge
37    /// base's bucket or directory deleted after startup. Reported, but not a
38    /// readiness failure — the backend is up.
39    NotFound,
40    /// A bucket stores a `PUT` whose `If-Match` or `If-None-Match` does not hold,
41    /// found at startup and accepted by the operator (D70). Reported, but not a
42    /// readiness failure — every request is served, and concurrent writes can be lost.
43    PreconditionsNotEnforced,
44}
45
46impl Unready {
47    /// Whether this reason means the backend itself is unavailable, as opposed
48    /// to answering that the probed thing is gone.
49    #[must_use]
50    pub fn is_outage(self) -> bool {
51        match self {
52            Self::Timeout | Self::Unreachable => true,
53            Self::NotFound | Self::PreconditionsNotEnforced => false,
54        }
55    }
56
57    /// The `reason` value the route renders.
58    #[must_use]
59    pub fn as_str(self) -> &'static str {
60        match self {
61            Self::Timeout => "timeout",
62            Self::Unreachable => "unreachable",
63            Self::NotFound => "not_found",
64            Self::PreconditionsNotEnforced => "preconditions_not_enforced",
65        }
66    }
67}
68
69/// One backend's latest probe.
70#[derive(Clone, Debug, PartialEq, Eq)]
71pub struct Check {
72    /// The selector value the backend answers to (`s3`, `fs`, `qdrant`), so a
73    /// reader can tell which deployment choice is failing.
74    pub backend: &'static str,
75    /// `Ok` when the last probe succeeded.
76    pub outcome: Result<(), Unready>,
77}
78
79impl Check {
80    /// A check whose last probe succeeded.
81    #[must_use]
82    pub fn ok(backend: &'static str) -> Self {
83        Self {
84            backend,
85            outcome: Ok(()),
86        }
87    }
88
89    /// A check whose last probe failed for `reason`.
90    #[must_use]
91    pub fn unready(backend: &'static str, reason: Unready) -> Self {
92        Self {
93            backend,
94            outcome: Err(reason),
95        }
96    }
97
98    /// Whether this check leaves the replica ready: the last probe succeeded,
99    /// or failed in a way that is not the backend's outage.
100    #[must_use]
101    pub fn is_ready(&self) -> bool {
102        match self.outcome {
103            Ok(()) => true,
104            Err(reason) => !reason.is_outage(),
105        }
106    }
107
108    /// Whether the last probe answered but found the probed thing gone: the
109    /// replica stays ready, and the body says so at the top as well.
110    #[must_use]
111    pub fn is_degraded(&self) -> bool {
112        matches!(self.outcome, Err(reason) if !reason.is_outage())
113    }
114
115    /// The `{"backend", "status", "reason"?}` object the route renders:
116    /// `ok`, `degraded` (answered, but the probed thing is gone) or
117    /// `unavailable`.
118    #[must_use]
119    pub fn to_json(&self) -> serde_json::Value {
120        match self.outcome {
121            Ok(()) => serde_json::json!({ "backend": self.backend, "status": "ok" }),
122            Err(reason) => serde_json::json!({
123                "backend": self.backend,
124                "status": if reason.is_outage() { "unavailable" } else { "degraded" },
125                "reason": reason.as_str(),
126            }),
127        }
128    }
129}
130
131/// The latest probe of every backend the poller covers.
132#[derive(Clone, Debug, PartialEq, Eq)]
133pub struct ReadinessSnapshot {
134    /// The object store: `s3` or `fs`.
135    pub storage: Check,
136    /// The vector store behind search and indexing.
137    pub search: Check,
138    /// Whether every bucket enforces conditional writes, as found once at startup on
139    /// the `s3` backend (D70). `None` on `fs`, which enforces them itself. Never
140    /// rewritten by the poller.
141    pub conditional_writes: Option<Check>,
142}
143
144impl ReadinessSnapshot {
145    /// Every check ok — what the poller publishes before its first probe,
146    /// since startup provisioning has just reached both backends.
147    #[must_use]
148    pub fn ok(storage_backend: &'static str, search_backend: &'static str) -> Self {
149        Self {
150            storage: Check::ok(storage_backend),
151            search: Check::ok(search_backend),
152            conditional_writes: None,
153        }
154    }
155
156    /// The same snapshot, carrying the startup finding on conditional writes.
157    #[must_use]
158    pub fn with_conditional_writes(mut self, check: Check) -> Self {
159        self.conditional_writes = Some(check);
160        self
161    }
162
163    /// Whether every check leaves the replica ready (see [`Check::is_ready`]).
164    #[must_use]
165    pub fn is_ready(&self) -> bool {
166        self.storage.is_ready()
167            && self.search.is_ready()
168            && self.conditional_writes.as_ref().is_none_or(Check::is_ready)
169    }
170
171    /// Whether any check is degraded (see [`Check::is_degraded`]).
172    #[must_use]
173    pub fn is_degraded(&self) -> bool {
174        self.storage.is_degraded()
175            || self.search.is_degraded()
176            || self
177                .conditional_writes
178                .as_ref()
179                .is_some_and(Check::is_degraded)
180    }
181}
182
183/// The route's end of the channel the poller publishes on.
184///
185/// `borrow()` keeps answering with the last value after the sender is gone,
186/// so a test can hand a state a fixed snapshot without running a poller.
187pub type ReadinessReceiver = tokio::sync::watch::Receiver<ReadinessSnapshot>;
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    #[test]
194    fn an_ok_check_renders_without_a_reason() {
195        assert_eq!(
196            Check::ok("s3").to_json(),
197            serde_json::json!({ "backend": "s3", "status": "ok" })
198        );
199    }
200
201    #[test]
202    fn an_unready_check_renders_its_reason_and_nothing_else() {
203        let json = Check::unready("qdrant", Unready::Timeout).to_json();
204        assert_eq!(
205            json,
206            serde_json::json!({ "backend": "qdrant", "status": "unavailable", "reason": "timeout" })
207        );
208    }
209
210    #[test]
211    fn reasons_are_the_documented_vocabulary() {
212        for (reason, expected) in [
213            (Unready::Timeout, "timeout"),
214            (Unready::Unreachable, "unreachable"),
215            (Unready::NotFound, "not_found"),
216            (
217                Unready::PreconditionsNotEnforced,
218                "preconditions_not_enforced",
219            ),
220        ] {
221            assert_eq!(reason.as_str(), expected);
222            assert_eq!(serde_json::to_value(reason).unwrap(), expected);
223        }
224    }
225
226    #[test]
227    fn a_not_found_check_is_degraded_and_still_ready() {
228        let check = Check::unready("fs", Unready::NotFound);
229        assert!(check.is_ready());
230        assert!(check.is_degraded());
231        assert!(!Check::ok("fs").is_degraded());
232        assert!(!Check::unready("fs", Unready::Timeout).is_degraded());
233        assert_eq!(
234            check.to_json(),
235            serde_json::json!({ "backend": "fs", "status": "degraded", "reason": "not_found" })
236        );
237    }
238
239    #[test]
240    fn ready_unless_a_backend_is_out() {
241        assert!(ReadinessSnapshot::ok("fs", "qdrant").is_ready());
242        let witness_gone = ReadinessSnapshot {
243            storage: Check::unready("fs", Unready::NotFound),
244            search: Check::ok("qdrant"),
245            conditional_writes: None,
246        };
247        assert!(witness_gone.is_ready(), "the backend answered; it is up");
248        assert!(
249            witness_gone.is_degraded(),
250            "and the body says so at the top"
251        );
252        assert!(!ReadinessSnapshot::ok("fs", "qdrant").is_degraded());
253        for reason in [Unready::Timeout, Unready::Unreachable] {
254            let storage_down = ReadinessSnapshot {
255                storage: Check::unready("s3", reason),
256                search: Check::ok("qdrant"),
257                conditional_writes: None,
258            };
259            assert!(!storage_down.is_ready());
260            let search_down = ReadinessSnapshot {
261                storage: Check::ok("fs"),
262                search: Check::unready("qdrant", reason),
263                conditional_writes: None,
264            };
265            assert!(!search_down.is_ready());
266        }
267    }
268
269    #[test]
270    fn unenforced_conditional_writes_are_degraded_and_still_ready() {
271        let snapshot = ReadinessSnapshot::ok("s3", "qdrant")
272            .with_conditional_writes(Check::unready("s3", Unready::PreconditionsNotEnforced));
273        assert!(snapshot.is_ready(), "every request is still served");
274        assert!(snapshot.is_degraded());
275        let enforced =
276            ReadinessSnapshot::ok("s3", "qdrant").with_conditional_writes(Check::ok("s3"));
277        assert!(!enforced.is_degraded());
278    }
279}