1use serde::Serialize;
26
27#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
30#[serde(rename_all = "snake_case")]
31pub enum Unready {
32 Timeout,
34 Unreachable,
36 NotFound,
40 PreconditionsNotEnforced,
44}
45
46impl Unready {
47 #[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 #[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#[derive(Clone, Debug, PartialEq, Eq)]
71pub struct Check {
72 pub backend: &'static str,
75 pub outcome: Result<(), Unready>,
77}
78
79impl Check {
80 #[must_use]
82 pub fn ok(backend: &'static str) -> Self {
83 Self {
84 backend,
85 outcome: Ok(()),
86 }
87 }
88
89 #[must_use]
91 pub fn unready(backend: &'static str, reason: Unready) -> Self {
92 Self {
93 backend,
94 outcome: Err(reason),
95 }
96 }
97
98 #[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 #[must_use]
111 pub fn is_degraded(&self) -> bool {
112 matches!(self.outcome, Err(reason) if !reason.is_outage())
113 }
114
115 #[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#[derive(Clone, Debug, PartialEq, Eq)]
133pub struct ReadinessSnapshot {
134 pub storage: Check,
136 pub search: Check,
138 pub conditional_writes: Option<Check>,
142}
143
144impl ReadinessSnapshot {
145 #[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 #[must_use]
158 pub fn with_conditional_writes(mut self, check: Check) -> Self {
159 self.conditional_writes = Some(check);
160 self
161 }
162
163 #[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 #[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
183pub 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}