use serde::Serialize;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Unready {
Timeout,
Unreachable,
NotFound,
}
impl Unready {
#[must_use]
pub fn is_outage(self) -> bool {
match self {
Self::Timeout | Self::Unreachable => true,
Self::NotFound => false,
}
}
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Timeout => "timeout",
Self::Unreachable => "unreachable",
Self::NotFound => "not_found",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Check {
pub backend: &'static str,
pub outcome: Result<(), Unready>,
}
impl Check {
#[must_use]
pub fn ok(backend: &'static str) -> Self {
Self {
backend,
outcome: Ok(()),
}
}
#[must_use]
pub fn unready(backend: &'static str, reason: Unready) -> Self {
Self {
backend,
outcome: Err(reason),
}
}
#[must_use]
pub fn is_ready(&self) -> bool {
match self.outcome {
Ok(()) => true,
Err(reason) => !reason.is_outage(),
}
}
#[must_use]
pub fn is_degraded(&self) -> bool {
matches!(self.outcome, Err(reason) if !reason.is_outage())
}
#[must_use]
pub fn to_json(&self) -> serde_json::Value {
match self.outcome {
Ok(()) => serde_json::json!({ "backend": self.backend, "status": "ok" }),
Err(reason) => serde_json::json!({
"backend": self.backend,
"status": if reason.is_outage() { "unavailable" } else { "degraded" },
"reason": reason.as_str(),
}),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ReadinessSnapshot {
pub storage: Check,
pub search: Check,
}
impl ReadinessSnapshot {
#[must_use]
pub fn ok(storage_backend: &'static str, search_backend: &'static str) -> Self {
Self {
storage: Check::ok(storage_backend),
search: Check::ok(search_backend),
}
}
#[must_use]
pub fn is_ready(&self) -> bool {
self.storage.is_ready() && self.search.is_ready()
}
#[must_use]
pub fn is_degraded(&self) -> bool {
self.storage.is_degraded() || self.search.is_degraded()
}
}
pub type ReadinessReceiver = tokio::sync::watch::Receiver<ReadinessSnapshot>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_ok_check_renders_without_a_reason() {
assert_eq!(
Check::ok("s3").to_json(),
serde_json::json!({ "backend": "s3", "status": "ok" })
);
}
#[test]
fn an_unready_check_renders_its_reason_and_nothing_else() {
let json = Check::unready("qdrant", Unready::Timeout).to_json();
assert_eq!(
json,
serde_json::json!({ "backend": "qdrant", "status": "unavailable", "reason": "timeout" })
);
}
#[test]
fn reasons_are_the_documented_vocabulary() {
for (reason, expected) in [
(Unready::Timeout, "timeout"),
(Unready::Unreachable, "unreachable"),
(Unready::NotFound, "not_found"),
] {
assert_eq!(reason.as_str(), expected);
assert_eq!(serde_json::to_value(reason).unwrap(), expected);
}
}
#[test]
fn a_not_found_check_is_degraded_and_still_ready() {
let check = Check::unready("fs", Unready::NotFound);
assert!(check.is_ready());
assert!(check.is_degraded());
assert!(!Check::ok("fs").is_degraded());
assert!(!Check::unready("fs", Unready::Timeout).is_degraded());
assert_eq!(
check.to_json(),
serde_json::json!({ "backend": "fs", "status": "degraded", "reason": "not_found" })
);
}
#[test]
fn ready_unless_a_backend_is_out() {
assert!(ReadinessSnapshot::ok("fs", "qdrant").is_ready());
let witness_gone = ReadinessSnapshot {
storage: Check::unready("fs", Unready::NotFound),
search: Check::ok("qdrant"),
};
assert!(witness_gone.is_ready(), "the backend answered; it is up");
assert!(
witness_gone.is_degraded(),
"and the body says so at the top"
);
assert!(!ReadinessSnapshot::ok("fs", "qdrant").is_degraded());
for reason in [Unready::Timeout, Unready::Unreachable] {
let storage_down = ReadinessSnapshot {
storage: Check::unready("s3", reason),
search: Check::ok("qdrant"),
};
assert!(!storage_down.is_ready());
let search_down = ReadinessSnapshot {
storage: Check::ok("fs"),
search: Check::unready("qdrant", reason),
};
assert!(!search_down.is_ready());
}
}
}