#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
#![cfg_attr(
not(test),
deny(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::unreachable,
clippy::todo,
clippy::unimplemented,
clippy::indexing_slicing,
clippy::string_slice,
clippy::arithmetic_side_effects,
)
)]
use std::collections::HashMap;
use std::sync::{Arc, Mutex, PoisonError};
use std::time::Duration;
use chrono::{DateTime, Utc};
use crate::actuator::{HealthCheckOutput, HealthIndicator, HealthStatus, IndicatorGroup};
pub const INDICATOR_NAME: &str = "sqlite-replication";
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ReplicationSnapshot {
pub destination: String,
pub generation: Option<String>,
pub last_success_at: Option<DateTime<Utc>>,
pub last_shipped_at: Option<DateTime<Utc>>,
pub segments_shipped: u64,
pub snapshots_taken: u64,
pub bytes_shipped: u64,
pub pending_bytes: u64,
pub last_error: Option<String>,
pub last_error_at: Option<DateTime<Utc>>,
pub consecutive_failures: u32,
pub last_verified_at: Option<DateTime<Utc>>,
pub last_verify_error: Option<String>,
}
impl ReplicationSnapshot {
#[must_use]
pub fn lag(&self, now: DateTime<Utc>) -> Option<Duration> {
let last = self.last_success_at?;
Some(
now.signed_duration_since(last)
.to_std()
.unwrap_or(Duration::ZERO),
)
}
}
#[derive(Debug)]
pub struct ReplicationStatus {
inner: Mutex<ReplicationSnapshot>,
}
impl ReplicationStatus {
#[must_use]
pub fn new(destination: impl Into<String>) -> Self {
Self {
inner: Mutex::new(ReplicationSnapshot {
destination: destination.into(),
..ReplicationSnapshot::default()
}),
}
}
fn with<R>(&self, f: impl FnOnce(&mut ReplicationSnapshot) -> R) -> R {
let mut guard = self.inner.lock().unwrap_or_else(PoisonError::into_inner);
f(&mut guard)
}
#[must_use]
pub fn snapshot(&self) -> ReplicationSnapshot {
self.with(|s| s.clone())
}
pub fn record_generation(&self, generation: &str, at: DateTime<Utc>) {
self.with(|s| {
s.generation = Some(generation.to_owned());
s.snapshots_taken = s.snapshots_taken.saturating_add(1);
s.last_success_at = Some(at);
s.last_error = None;
s.last_error_at = None;
s.consecutive_failures = 0;
});
}
pub fn record_segment(&self, bytes: u64, at: DateTime<Utc>) {
self.with(|s| {
s.segments_shipped = s.segments_shipped.saturating_add(1);
s.bytes_shipped = s.bytes_shipped.saturating_add(bytes);
s.last_shipped_at = Some(at);
});
}
pub fn record_tick_behind(&self, pending_bytes: u64) {
self.with(|s| s.pending_bytes = pending_bytes);
}
pub fn record_tick_ok(&self, pending_bytes: u64, at: DateTime<Utc>) {
self.with(|s| {
s.pending_bytes = pending_bytes;
s.last_success_at = Some(at);
s.last_error = None;
s.last_error_at = None;
s.consecutive_failures = 0;
});
}
pub fn record_tick_error(&self, error: impl Into<String>, at: DateTime<Utc>) {
self.with(|s| {
s.last_error = Some(error.into());
s.last_error_at = Some(at);
s.consecutive_failures = s.consecutive_failures.saturating_add(1);
});
}
pub fn record_verification(&self, result: Result<(), String>, at: DateTime<Utc>) {
self.with(|s| match result {
Ok(()) => {
s.last_verified_at = Some(at);
s.last_verify_error = None;
}
Err(detail) => s.last_verify_error = Some(detail),
});
}
}
#[derive(Debug, Clone, Copy)]
pub struct HealthThresholds {
pub lag_alert_after: Duration,
pub startup_grace: Duration,
}
#[derive(Debug)]
pub struct ReplicationHealthIndicator {
status: Arc<ReplicationStatus>,
thresholds: HealthThresholds,
started_at: DateTime<Utc>,
}
impl ReplicationHealthIndicator {
#[must_use]
pub const fn new(
status: Arc<ReplicationStatus>,
thresholds: HealthThresholds,
started_at: DateTime<Utc>,
) -> Self {
Self {
status,
thresholds,
started_at,
}
}
#[must_use]
pub fn evaluate(
&self,
snapshot: &ReplicationSnapshot,
now: DateTime<Utc>,
) -> HealthCheckOutput {
let mut details: HashMap<String, serde_json::Value> = HashMap::new();
details.insert(
"destination".to_owned(),
serde_json::Value::String(snapshot.destination.clone()),
);
if let Some(generation) = &snapshot.generation {
details.insert(
"generation".to_owned(),
serde_json::Value::String(generation.clone()),
);
}
details.insert(
"segments_shipped".to_owned(),
serde_json::Value::from(snapshot.segments_shipped),
);
details.insert(
"pending_bytes".to_owned(),
serde_json::Value::from(snapshot.pending_bytes),
);
details.insert(
"lag_alert_after_seconds".to_owned(),
serde_json::Value::from(self.thresholds.lag_alert_after.as_secs()),
);
if let Some(at) = snapshot.last_verified_at {
details.insert(
"last_verified_at".to_owned(),
serde_json::Value::String(at.to_rfc3339()),
);
}
let lag = snapshot.lag(now).or_else(|| {
now.signed_duration_since(self.started_at).to_std().ok()
});
if let Some(lag) = lag {
details.insert(
"lag_seconds".to_owned(),
serde_json::Value::from(lag.as_secs()),
);
}
if let Some(detail) = &snapshot.last_verify_error {
details.insert(
"verification_error".to_owned(),
serde_json::Value::String(detail.clone()),
);
return HealthCheckOutput {
status: HealthStatus::Down,
details,
};
}
if let Some(error) = &snapshot.last_error {
details.insert("error".to_owned(), serde_json::Value::String(error.clone()));
}
let grace = if snapshot.last_success_at.is_some() {
Duration::ZERO
} else {
self.thresholds.startup_grace
};
let over_lag =
lag.is_some_and(|lag| lag > self.thresholds.lag_alert_after.saturating_add(grace));
if over_lag {
HealthCheckOutput {
status: HealthStatus::Down,
details,
}
} else {
HealthCheckOutput {
status: HealthStatus::Up,
details,
}
}
}
}
impl HealthIndicator for ReplicationHealthIndicator {
fn check(&self) -> futures::future::BoxFuture<'_, HealthCheckOutput> {
Box::pin(async move {
let snapshot = self.status.snapshot();
self.evaluate(&snapshot, crate::time::ambient_now())
})
}
fn group(&self) -> IndicatorGroup {
IndicatorGroup::HealthOnly
}
}
#[cfg(test)]
mod tests {
use super::*;
fn indicator(
status: &Arc<ReplicationStatus>,
started_at: DateTime<Utc>,
) -> ReplicationHealthIndicator {
ReplicationHealthIndicator::new(
Arc::clone(status),
HealthThresholds {
lag_alert_after: Duration::from_secs(30),
startup_grace: Duration::from_secs(60),
},
started_at,
)
}
fn at(secs: i64) -> DateTime<Utc> {
DateTime::from_timestamp(1_700_000_000 + secs, 0).expect("timestamp")
}
#[test]
fn a_freshly_started_replicator_is_up_during_the_startup_grace() {
let status = Arc::new(ReplicationStatus::new("file:///replicas"));
let health = indicator(&status, at(0));
let output = health.evaluate(&status.snapshot(), at(10));
assert_eq!(output.status, HealthStatus::Up);
assert_eq!(
output.details.get("destination").and_then(|v| v.as_str()),
Some("file:///replicas")
);
}
#[test]
fn a_replicator_that_never_ships_goes_down_after_the_grace() {
let status = Arc::new(ReplicationStatus::new("file:///replicas"));
let health = indicator(&status, at(0));
assert_eq!(
health.evaluate(&status.snapshot(), at(200)).status,
HealthStatus::Down
);
}
#[test]
fn lag_past_the_threshold_is_down_and_recovers_on_the_next_success() {
let status = Arc::new(ReplicationStatus::new("file:///replicas"));
let health = indicator(&status, at(0));
status.record_tick_ok(0, at(10));
assert_eq!(
health.evaluate(&status.snapshot(), at(20)).status,
HealthStatus::Up
);
let down = health.evaluate(&status.snapshot(), at(100));
assert_eq!(down.status, HealthStatus::Down);
assert_eq!(
down.details
.get("lag_seconds")
.and_then(serde_json::Value::as_u64),
Some(90)
);
status.record_tick_ok(0, at(100));
assert_eq!(
health.evaluate(&status.snapshot(), at(105)).status,
HealthStatus::Up
);
}
#[test]
fn a_failing_tick_does_not_advance_the_replication_point() {
let status = Arc::new(ReplicationStatus::new("file:///replicas"));
status.record_tick_ok(0, at(10));
status.record_tick_error("endpoint unreachable", at(20));
status.record_tick_error("endpoint unreachable", at(25));
let snapshot = status.snapshot();
assert_eq!(snapshot.last_success_at, Some(at(10)));
assert_eq!(snapshot.consecutive_failures, 2);
assert_eq!(snapshot.lag(at(40)), Some(Duration::from_secs(30)));
let health = indicator(&status, at(0));
let output = health.evaluate(&snapshot, at(60));
assert_eq!(output.status, HealthStatus::Down);
assert_eq!(
output.details.get("error").and_then(|v| v.as_str()),
Some("endpoint unreachable")
);
}
#[test]
fn a_failed_verification_is_down_even_when_shipping_is_current() {
let status = Arc::new(ReplicationStatus::new("file:///replicas"));
let health = indicator(&status, at(0));
status.record_tick_ok(0, at(100));
status.record_verification(Err("integrity check failed".to_owned()), at(100));
let output = health.evaluate(&status.snapshot(), at(101));
assert_eq!(
output.status,
HealthStatus::Down,
"uploaded is not the same as restorable"
);
assert_eq!(
output
.details
.get("verification_error")
.and_then(|v| v.as_str()),
Some("integrity check failed")
);
status.record_tick_ok(0, at(200));
status.record_verification(Ok(()), at(200));
let recovered = health.evaluate(&status.snapshot(), at(201));
assert_eq!(recovered.status, HealthStatus::Up);
assert!(recovered.details.contains_key("last_verified_at"));
}
#[test]
fn counters_accumulate_across_ticks() {
let status = Arc::new(ReplicationStatus::new("s3://bucket"));
status.record_generation("0000000001000-0000000000000001", at(1));
status.record_segment(4_120, at(2));
status.record_segment(8_240, at(3));
let snapshot = status.snapshot();
assert_eq!(snapshot.snapshots_taken, 1);
assert_eq!(snapshot.segments_shipped, 2);
assert_eq!(snapshot.bytes_shipped, 12_360);
assert_eq!(snapshot.last_shipped_at, Some(at(3)));
assert_eq!(
snapshot.generation.as_deref(),
Some("0000000001000-0000000000000001")
);
}
#[test]
fn the_indicator_is_health_only_so_it_never_pulls_a_replica_out_of_service() {
let status = Arc::new(ReplicationStatus::new("file:///replicas"));
assert_eq!(
indicator(&status, at(0)).group(),
IndicatorGroup::HealthOnly
);
}
}