use crate::infrastructure::config::redact_userinfo;
use crate::infrastructure::{MongoDBRepository, RedisClient, SimulationSnapshotRepository};
use async_trait::async_trait;
use futures::future::join_all;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use tracing::{info, warn};
pub(crate) const PROBE_TIMEOUT: Duration = Duration::from_secs(2);
pub(crate) const MAX_DETAIL_CHARS: usize = 200;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProbeFailure {
Unreachable,
TimedOut,
}
impl ProbeFailure {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
ProbeFailure::Unreachable => "unreachable",
ProbeFailure::TimedOut => "timed_out",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DependencyReport {
pub name: &'static str,
pub failure: Option<ProbeFailure>,
}
impl DependencyReport {
#[must_use]
pub fn is_up(&self) -> bool {
self.failure.is_none()
}
}
#[async_trait]
pub trait DependencyProbe: Send + Sync {
fn name(&self) -> &'static str;
async fn check(&self) -> Result<(), String>;
}
pub struct RedisProbe(Arc<RedisClient>);
impl RedisProbe {
#[must_use]
pub fn new(client: Arc<RedisClient>) -> Self {
Self(client)
}
}
#[async_trait]
impl DependencyProbe for RedisProbe {
fn name(&self) -> &'static str {
"redis"
}
async fn check(&self) -> Result<(), String> {
self.0.ping().await.map_err(|error| error.to_string())
}
}
pub struct MongoDbProbe(Arc<MongoDBRepository>);
impl MongoDbProbe {
#[must_use]
pub fn new(repository: Arc<MongoDBRepository>) -> Self {
Self(repository)
}
}
#[async_trait]
impl DependencyProbe for MongoDbProbe {
fn name(&self) -> &'static str {
"mongodb"
}
async fn check(&self) -> Result<(), String> {
self.0.ping().await.map_err(|error| error.to_string())
}
}
pub struct WarehouseProbe(Arc<dyn SimulationSnapshotRepository>);
impl WarehouseProbe {
#[must_use]
pub fn new(warehouse: Arc<dyn SimulationSnapshotRepository>) -> Self {
Self(warehouse)
}
}
#[async_trait]
impl DependencyProbe for WarehouseProbe {
fn name(&self) -> &'static str {
"clickhouse"
}
async fn check(&self) -> Result<(), String> {
self.0.ping().await.map_err(|error| error.to_string())
}
}
#[derive(Clone, Default)]
pub struct Readiness {
probes: Arc<Vec<Arc<dyn DependencyProbe>>>,
last_logged_ready: Arc<AtomicBool>,
}
impl Readiness {
#[must_use]
pub fn new(probes: Vec<Arc<dyn DependencyProbe>>) -> Self {
Self {
probes: Arc::new(probes),
last_logged_ready: Arc::new(AtomicBool::new(true)),
}
}
pub async fn evaluate(&self) -> Vec<DependencyReport> {
let answers = join_all(self.probes.iter().map(|probe| async move {
let name = probe.name();
match tokio::time::timeout(PROBE_TIMEOUT, probe.check()).await {
Ok(Ok(())) => (name, None, None),
Ok(Err(detail)) => (
name,
Some(ProbeFailure::Unreachable),
Some(bound_detail(&redact_userinfo(&detail))),
),
Err(_) => (name, Some(ProbeFailure::TimedOut), None),
}
}))
.await;
let reports: Vec<DependencyReport> = answers
.iter()
.map(|(name, failure, _)| DependencyReport {
name,
failure: *failure,
})
.collect();
self.log_transition(&answers);
reports
}
fn log_transition(&self, answers: &[(&'static str, Option<ProbeFailure>, Option<String>)]) {
let ready = answers.iter().all(|(_, failure, _)| failure.is_none());
if self.last_logged_ready.swap(ready, Ordering::SeqCst) == ready {
return;
}
if ready {
info!("every dependency answered again; this instance is ready");
return;
}
for (name, failure, detail) in answers.iter().filter(|(_, failure, _)| failure.is_some()) {
warn!(
dependency = name,
reason = failure.map_or("", ProbeFailure::as_str),
detail = detail.as_deref().unwrap_or(""),
"a dependency stopped answering; this instance is not ready"
);
}
}
}
fn bound_detail(detail: &str) -> String {
if detail.chars().count() <= MAX_DETAIL_CHARS {
return detail.to_string();
}
let kept: String = detail.chars().take(MAX_DETAIL_CHARS).collect();
format!("{kept}...")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::infrastructure::clickhouse::snapshots::interface::ContractSeriesQuery;
use crate::infrastructure::{ContractQuote, SnapshotRecord};
use crate::utils::ChainError;
use uuid::Uuid;
struct Switch {
name: &'static str,
up: AtomicBool,
detail: String,
}
impl Switch {
fn new(name: &'static str, up: bool) -> Self {
Self {
name,
up: AtomicBool::new(up),
detail: "connection refused".to_string(),
}
}
fn failing_with(name: &'static str, detail: &str) -> Self {
Self {
name,
up: AtomicBool::new(false),
detail: detail.to_string(),
}
}
}
#[async_trait]
impl DependencyProbe for Switch {
fn name(&self) -> &'static str {
self.name
}
async fn check(&self) -> Result<(), String> {
if self.up.load(Ordering::SeqCst) {
Ok(())
} else {
Err(self.detail.clone())
}
}
}
struct Hangs;
#[async_trait]
impl DependencyProbe for Hangs {
fn name(&self) -> &'static str {
"stalled"
}
async fn check(&self) -> Result<(), String> {
tokio::time::sleep(PROBE_TIMEOUT * 10).await;
Ok(())
}
}
struct UnreachableWarehouse;
#[async_trait]
impl SimulationSnapshotRepository for UnreachableWarehouse {
async fn ping(&self) -> Result<(), ChainError> {
Err(ChainError::ClickHouseError(
"connection refused by clickhouse:8123".to_string(),
))
}
async fn persist(&self, _record: SnapshotRecord) -> Result<(), ChainError> {
Err(ChainError::ClickHouseError("unreachable".to_string()))
}
async fn get(
&self,
_simulation: Uuid,
_generation: u64,
_step: usize,
) -> Result<Option<SnapshotRecord>, ChainError> {
Err(ChainError::ClickHouseError("unreachable".to_string()))
}
async fn read_range(
&self,
_simulation: Uuid,
_generation: u64,
_from_step: usize,
_to_step: usize,
) -> Result<Vec<SnapshotRecord>, ChainError> {
Err(ChainError::ClickHouseError("unreachable".to_string()))
}
async fn contract_series(
&self,
_query: ContractSeriesQuery,
) -> Result<Vec<ContractQuote>, ChainError> {
Err(ChainError::ClickHouseError("unreachable".to_string()))
}
}
#[derive(Default)]
struct LocalWarehouse;
#[async_trait]
impl SimulationSnapshotRepository for LocalWarehouse {
async fn ping(&self) -> Result<(), ChainError> {
Ok(())
}
async fn persist(&self, _record: SnapshotRecord) -> Result<(), ChainError> {
Ok(())
}
async fn get(
&self,
_simulation: Uuid,
_generation: u64,
_step: usize,
) -> Result<Option<SnapshotRecord>, ChainError> {
Ok(None)
}
async fn read_range(
&self,
_simulation: Uuid,
_generation: u64,
_from_step: usize,
_to_step: usize,
) -> Result<Vec<SnapshotRecord>, ChainError> {
Ok(Vec::new())
}
async fn contract_series(
&self,
_query: ContractSeriesQuery,
) -> Result<Vec<ContractQuote>, ChainError> {
Ok(Vec::new())
}
}
#[tokio::test]
async fn test_every_probe_is_reported_in_order() {
let readiness = Readiness::new(vec![
Arc::new(Switch::new("redis", true)),
Arc::new(Switch::new("mongodb", true)),
Arc::new(Switch::new("clickhouse", true)),
]);
let reports = readiness.evaluate().await;
assert_eq!(
reports.iter().map(|report| report.name).collect::<Vec<_>>(),
vec!["redis", "mongodb", "clickhouse"]
);
assert!(reports.iter().all(DependencyReport::is_up));
}
#[tokio::test]
async fn test_a_failure_is_named_beside_the_healthy_dependencies() {
let readiness = Readiness::new(vec![
Arc::new(Switch::new("redis", false)),
Arc::new(Switch::new("mongodb", true)),
]);
let reports = readiness.evaluate().await;
match reports.iter().find(|report| report.name == "redis") {
Some(redis) => assert_eq!(redis.failure, Some(ProbeFailure::Unreachable)),
None => panic!("the failing dependency must be reported: {reports:?}"),
}
match reports.iter().find(|report| report.name == "mongodb") {
Some(mongodb) => assert!(mongodb.is_up()),
None => panic!("every dependency must be reported: {reports:?}"),
}
}
#[tokio::test]
async fn test_a_recovered_dependency_reports_up_again() {
let switch = Arc::new(Switch::new("redis", false));
let readiness = Readiness::new(vec![switch.clone()]);
assert!(!readiness.evaluate().await[0].is_up());
switch.up.store(true, Ordering::SeqCst);
assert!(
readiness.evaluate().await[0].is_up(),
"nothing may be cached across evaluations"
);
}
#[tokio::test(start_paused = true)]
async fn test_a_hung_dependency_times_out() {
let readiness = Readiness::new(vec![Arc::new(Hangs), Arc::new(Switch::new("redis", true))]);
let reports = readiness.evaluate().await;
match reports.iter().find(|report| report.name == "stalled") {
Some(stalled) => assert_eq!(
stalled.failure,
Some(ProbeFailure::TimedOut),
"the report says it timed out, not that it refused"
),
None => panic!("the hung dependency must be reported: {reports:?}"),
}
match reports.iter().find(|report| report.name == "redis") {
Some(redis) => assert!(redis.is_up(), "the probes run at once"),
None => panic!("every dependency must be reported: {reports:?}"),
}
}
#[tokio::test]
async fn test_a_report_carries_no_driver_text() {
let leaky = "IO error: redis://admin:hunter2@10.0.0.7:6379/prod?tls_cert=/etc/ssl/k.pem";
let readiness = Readiness::new(vec![Arc::new(Switch::failing_with("redis", leaky))]);
let reports = readiness.evaluate().await;
assert_eq!(reports[0].failure, Some(ProbeFailure::Unreachable));
let rendered = format!("{reports:?}");
for secret in ["hunter2", "10.0.0.7", "/etc/ssl/k.pem", "prod"] {
assert!(
!rendered.contains(secret),
"the report leaked {secret:?}: {rendered}"
);
}
}
#[tokio::test]
async fn test_the_public_vocabulary_is_two_words() {
assert_eq!(ProbeFailure::Unreachable.as_str(), "unreachable");
assert_eq!(ProbeFailure::TimedOut.as_str(), "timed_out");
}
#[tokio::test]
async fn test_the_internal_detail_is_sanitised() {
let redacted = bound_detail(&crate::infrastructure::config::redact_userinfo(
"IO error: redis://admin:hunter2@redis:6379 refused",
));
assert!(!redacted.contains("hunter2"), "the password survived");
assert!(redacted.contains("refused"), "the reason was lost");
let long = bound_detail(&"x".repeat(MAX_DETAIL_CHARS * 5));
assert_eq!(long.chars().count(), MAX_DETAIL_CHARS + 3);
assert!(long.ends_with("..."));
assert_eq!(bound_detail("connection refused"), "connection refused");
}
#[tokio::test]
async fn test_no_probes_is_ready() {
assert!(Readiness::default().evaluate().await.is_empty());
}
#[tokio::test]
async fn test_the_warehouse_probe_reports_an_unreachable_warehouse() {
let probe = WarehouseProbe::new(Arc::new(UnreachableWarehouse));
assert_eq!(probe.name(), "clickhouse");
match probe.check().await {
Ok(()) => panic!("an unreachable warehouse must not report up"),
Err(detail) => assert!(
detail.contains("connection refused"),
"the warehouse's reason must survive: {detail}"
),
}
}
#[tokio::test]
async fn test_the_warehouse_probe_accepts_a_local_warehouse() {
let probe = WarehouseProbe::new(Arc::new(LocalWarehouse));
match probe.check().await {
Ok(()) => {}
Err(detail) => panic!("a local warehouse is reachable: {detail}"),
}
}
#[tokio::test]
#[ignore = "requires a live Redis on localhost:6379"]
async fn test_the_redis_probe_answers_against_a_live_server() {
let client = match RedisClient::new(crate::infrastructure::RedisConfig::default()).await {
Ok(client) => Arc::new(client),
Err(error) => panic!("Redis must be reachable for this test: {error}"),
};
let probe = RedisProbe::new(client);
assert_eq!(probe.name(), "redis");
match probe.check().await {
Ok(()) => {}
Err(detail) => panic!("a live server must answer: {detail}"),
}
}
#[tokio::test]
#[ignore = "requires a live MongoDB on localhost:27017"]
async fn test_the_mongodb_probe_answers_against_a_live_server() {
let repository = match crate::infrastructure::init_mongodb().await {
Ok(repository) => repository,
Err(error) => panic!("MongoDB must be reachable for this test: {error}"),
};
let probe = MongoDbProbe::new(repository);
assert_eq!(probe.name(), "mongodb");
match probe.check().await {
Ok(()) => {}
Err(detail) => panic!("a live server must answer: {detail}"),
}
}
}