use std::collections::{HashMap, HashSet};
use std::sync::Mutex;
use std::time::{Duration, Instant};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct HealthPolicy {
pub node_cooldown: Duration,
pub api_cooldown: Duration,
pub failures_before_cooldown: u32,
pub api_failures_before_cooldown: u32,
pub stale_block_threshold: u64,
pub head_block_ttl: Duration,
pub block_interval: Duration,
}
impl Default for HealthPolicy {
fn default() -> Self {
HealthPolicy {
node_cooldown: Duration::from_secs(30),
api_cooldown: Duration::from_secs(60),
failures_before_cooldown: 3,
api_failures_before_cooldown: 2,
stale_block_threshold: 30,
head_block_ttl: Duration::from_secs(120),
block_interval: Duration::from_secs(3),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NodeHealth {
pub consecutive_failures: u32,
pub in_cooldown: bool,
pub cooling_methods: Vec<String>,
pub head_block: Option<u64>,
pub stale: bool,
}
#[derive(Debug, Default)]
struct NodeState {
consecutive_failures: u32,
streak_methods: HashSet<String>,
cooldown_until: Option<Instant>,
method_failures: HashMap<String, u32>,
method_cooldown_until: HashMap<String, Instant>,
head_block: Option<(u64, Instant)>,
}
impl NodeState {
fn cooling(&self, now: Instant) -> bool {
self.cooldown_until.is_some_and(|t| t > now)
}
fn cooling_for(&self, method: &str, now: Instant) -> bool {
self.method_cooldown_until
.get(method)
.is_some_and(|t| *t > now)
}
fn fresh_head(&self, now: Instant, ttl: Duration) -> Option<u64> {
self.head_block
.filter(|(_, seen)| now.duration_since(*seen) <= ttl)
.map(|(block, _)| block)
}
fn projected_head(&self, now: Instant, ttl: Duration, interval: Duration) -> Option<f64> {
let (block, seen) = self.head_block?;
let age = now.duration_since(seen);
if age > ttl {
return None;
}
Some(project(block, age, interval))
}
}
fn best_of(projections: impl Iterator<Item = f64>) -> Option<f64> {
projections.fold(None, |acc, v| match acc {
Some(a) if a >= v => Some(a),
_ => Some(v),
})
}
fn blocks_behind(best: f64, mine: f64) -> u64 {
let gap = (best - mine).floor();
if gap <= 0.0 {
0
} else {
gap as u64
}
}
fn project(head: u64, age: Duration, interval: Duration) -> f64 {
let interval = interval.as_secs_f64();
if interval <= 0.0 {
return head as f64;
}
head as f64 + age.as_secs_f64() / interval
}
#[derive(Debug)]
pub struct HealthTracker {
policy: HealthPolicy,
state: Mutex<Vec<NodeState>>,
}
impl HealthTracker {
pub fn new(node_count: usize, policy: HealthPolicy) -> Self {
let mut state = Vec::with_capacity(node_count);
state.resize_with(node_count, NodeState::default);
HealthTracker {
policy,
state: Mutex::new(state),
}
}
pub fn policy(&self) -> HealthPolicy {
self.policy
}
pub fn order(&self, method: &str) -> Vec<usize> {
let now = Instant::now();
let state = self.state.lock().unwrap_or_else(|e| e.into_inner());
let best_head = self.best_projected(&state, now);
let mut tiers: Vec<(u8, usize)> = state
.iter()
.enumerate()
.map(|(i, s)| {
let tier = if s.cooling(now) {
3
} else if s.cooling_for(method, now) {
2
} else if self.is_stale(s, best_head, now) {
1
} else {
0
};
(tier, i)
})
.collect();
tiers.sort_by_key(|(tier, _)| *tier);
tiers.into_iter().map(|(_, i)| i).collect()
}
fn best_projected(&self, state: &[NodeState], now: Instant) -> Option<f64> {
best_of(state.iter().filter_map(|s| self.projected(s, now)))
}
fn projected(&self, s: &NodeState, now: Instant) -> Option<f64> {
s.projected_head(now, self.policy.head_block_ttl, self.policy.block_interval)
}
fn is_stale(&self, s: &NodeState, best_head: Option<f64>, now: Instant) -> bool {
let (Some(best), Some(mine)) = (best_head, self.projected(s, now)) else {
return false;
};
blocks_behind(best, mine) > self.policy.stale_block_threshold
}
pub fn record_success(&self, index: usize, method: &str) {
let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());
let Some(s) = state.get_mut(index) else {
return;
};
s.consecutive_failures = 0;
s.streak_methods.clear();
s.cooldown_until = None;
s.method_failures.remove(method);
s.method_cooldown_until.remove(method);
}
pub fn record_failure(&self, index: usize, method: &str) {
let now = Instant::now();
let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());
let Some(s) = state.get_mut(index) else {
return;
};
s.consecutive_failures = s.consecutive_failures.saturating_add(1);
s.streak_methods.insert(method.to_owned());
if s.consecutive_failures >= self.policy.failures_before_cooldown
&& s.streak_methods.len() > 1
{
s.cooldown_until = Some(now + self.policy.node_cooldown);
}
let counter = s.method_failures.entry(method.to_owned()).or_insert(0);
*counter = counter.saturating_add(1);
let hits = *counter;
if hits >= self.policy.api_failures_before_cooldown {
s.method_cooldown_until
.insert(method.to_owned(), now + self.policy.api_cooldown);
}
}
pub fn observe_head_block(&self, index: usize, head_block: u64) {
let now = Instant::now();
let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());
if let Some(s) = state.get_mut(index) {
s.head_block = Some((head_block, now));
}
}
#[cfg(test)]
fn observe_head_block_at(&self, index: usize, head_block: u64, at: Instant) {
let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());
if let Some(s) = state.get_mut(index) {
s.head_block = Some((head_block, at));
}
}
pub fn snapshot(&self) -> Vec<NodeHealth> {
let now = Instant::now();
let state = self.state.lock().unwrap_or_else(|e| e.into_inner());
let best_head = self.best_projected(&state, now);
state
.iter()
.map(|s| {
let mut cooling_methods: Vec<String> = s
.method_cooldown_until
.iter()
.filter(|(_, t)| **t > now)
.map(|(m, _)| m.clone())
.collect();
cooling_methods.sort();
NodeHealth {
consecutive_failures: s.consecutive_failures,
in_cooldown: s.cooling(now),
cooling_methods,
head_block: s.fresh_head(now, self.policy.head_block_ttl),
stale: self.is_stale(s, best_head, now),
}
})
.collect()
}
}
pub(crate) fn head_block_of(value: &serde_json::Value) -> Option<u64> {
value
.get("head_block_number")
.and_then(serde_json::Value::as_u64)
}
#[cfg(test)]
mod tests {
use super::*;
fn policy() -> HealthPolicy {
HealthPolicy {
failures_before_cooldown: 2,
api_failures_before_cooldown: 2,
..Default::default()
}
}
#[test]
fn a_healthy_list_comes_back_in_the_configured_order() {
let t = HealthTracker::new(3, policy());
assert_eq!(t.order("x"), vec![0, 1, 2]);
}
#[test]
fn a_failing_node_sorts_last() {
let t = HealthTracker::new(3, policy());
t.record_failure(0, "x");
assert_eq!(
t.order("x"),
vec![0, 1, 2],
"one failure must not move a node"
);
t.record_failure(0, "x");
assert_eq!(t.order("x"), vec![1, 2, 0]);
}
#[test]
fn one_success_clears_the_cooldown() {
let t = HealthTracker::new(3, policy());
t.record_failure(0, "x");
t.record_failure(0, "x");
assert_eq!(t.order("x"), vec![1, 2, 0]);
t.record_success(0, "x");
assert_eq!(t.order("x"), vec![0, 1, 2]);
}
#[test]
fn a_method_cooldown_does_not_move_the_node_for_other_methods() {
let t = HealthTracker::new(3, policy());
t.record_failure(0, "account_history_api.get_ops_in_block");
t.record_success(0, "database_api.get_accounts");
t.record_failure(0, "account_history_api.get_ops_in_block");
assert_eq!(
t.order("account_history_api.get_ops_in_block"),
vec![1, 2, 0],
"the failing pair must sort last"
);
assert_eq!(
t.order("database_api.get_accounts"),
vec![0, 1, 2],
"the working pair must be untouched"
);
}
#[test]
fn nodes_that_are_in_sync_never_appear_behind_at_all() {
let interval = HealthPolicy::default().block_interval;
let interval_ms = interval.as_millis() as u64;
let mut worst = 0u64;
for slow_ms in (200..10_000).step_by(100) {
for phase_ms in (0..interval_ms).step_by(50) {
let latencies = [60u64, 80, slow_ms];
let head_at = |t_ms: u64| (t_ms + phase_ms) / interval_ms;
let compare_at = *latencies.iter().max().expect("non-empty");
let projections: Vec<f64> = latencies
.iter()
.map(|&at| {
project(
head_at(at),
Duration::from_millis(compare_at - at),
interval,
)
})
.collect();
let best = best_of(projections.iter().copied()).expect("non-empty");
let worst_here = projections
.iter()
.map(|p| blocks_behind(best, *p))
.max()
.expect("non-empty");
worst = worst.max(worst_here);
}
}
assert_eq!(
worst, 0,
"nodes that are in sync must never appear behind at all; saw {worst}"
);
}
#[test]
fn the_freshest_reading_is_never_demoted_by_the_projection() {
let t = HealthTracker::new(
2,
HealthPolicy {
stale_block_threshold: 0,
block_interval: Duration::from_millis(10),
head_block_ttl: Duration::from_secs(10),
..Default::default()
},
);
let now = Instant::now();
let a_fraction_of_a_block_ago = now
.checked_sub(Duration::from_millis(6))
.expect("the monotonic clock is more than 6ms past its origin");
t.observe_head_block_at(0, 100, a_fraction_of_a_block_ago);
t.observe_head_block_at(1, 100, now);
let report = t.snapshot();
assert!(
!report[1].stale,
"the freshest reading must never be the stale one: {report:?}"
);
assert!(!report[0].stale, "and neither node is behind: {report:?}");
assert_eq!(t.order("x"), vec![0, 1], "so the order is untouched");
}
#[test]
fn a_whole_block_of_elapsed_time_is_credited() {
let t = HealthTracker::new(
2,
HealthPolicy {
stale_block_threshold: 0,
block_interval: Duration::from_millis(10),
head_block_ttl: Duration::from_secs(10),
..Default::default()
},
);
t.observe_head_block(0, 100);
std::thread::sleep(Duration::from_millis(35)); t.observe_head_block(1, 103);
let report = t.snapshot();
assert!(
!report[0].stale,
"three whole blocks passed and must be credited: {report:?}"
);
}
#[test]
fn the_staleness_boundary_is_bracketed() {
let threshold = HealthPolicy::default().stale_block_threshold;
let within = HealthTracker::new(2, HealthPolicy::default());
within.observe_head_block(0, 1_000);
within.observe_head_block(1, 1_000 + threshold);
assert!(
!within.snapshot()[0].stale,
"exactly at the threshold is within it, not past it"
);
let past = HealthTracker::new(2, HealthPolicy::default());
past.observe_head_block(0, 1_000);
past.observe_head_block(1, 1_000 + threshold + 2);
assert!(
past.snapshot()[0].stale,
"two past the threshold is unambiguously stale"
);
assert_eq!(within.order("x"), vec![0, 1]);
assert_eq!(past.order("x"), vec![1, 0]);
}
#[test]
fn a_latency_spread_cannot_demote_a_node() {
let t = HealthTracker::new(2, HealthPolicy::default());
t.observe_head_block(0, 100);
t.observe_head_block(1, 103);
let report = t.snapshot();
assert!(!report[0].stale, "3 blocks is nowhere near 30: {report:?}");
assert_eq!(t.order("x"), vec![0, 1], "and the order is untouched");
}
#[test]
fn the_timeout_bounds_the_artefact_below_the_threshold() {
let policy = HealthPolicy::default();
let timeout = super::super::DEFAULT_TIMEOUT;
let worst = timeout.as_secs_f64() / policy.block_interval.as_secs_f64();
let margin = policy.stale_block_threshold as f64 / worst;
assert!(
margin >= 3.0,
"the default timeout admits {worst:.1} blocks of artefact against a \
{}-block threshold, a margin of only {margin:.1}x. Either raise \
stale_block_threshold or lower the default timeout.",
policy.stale_block_threshold
);
}
#[test]
fn the_threshold_is_what_bounds_the_latency_artefact_not_the_arithmetic() {
let tight = HealthTracker::new(
2,
HealthPolicy {
stale_block_threshold: 1,
..Default::default()
},
);
tight.observe_head_block(0, 100);
tight.observe_head_block(1, 103);
assert!(
tight.snapshot()[0].stale,
"at a one-block threshold the artefact does bite"
);
}
#[test]
fn the_leading_node_is_never_stale_against_its_own_reading() {
let t = HealthTracker::new(3, HealthPolicy::default());
t.observe_head_block(0, 5_000);
t.observe_head_block(1, 10);
t.observe_head_block(2, 20);
let report = t.snapshot();
assert!(!report[0].stale, "the leader cannot be behind itself");
assert!(
report[1].stale && report[2].stale,
"the laggards are: {report:?}"
);
let solo = HealthTracker::new(1, HealthPolicy::default());
solo.observe_head_block(0, 1);
assert!(!solo.snapshot()[0].stale);
}
#[test]
fn a_node_behind_the_head_sorts_after_current_ones() {
let t = HealthTracker::new(3, policy());
t.observe_head_block(0, 1_000);
t.observe_head_block(1, 1_100);
t.observe_head_block(2, 1_100);
assert_eq!(t.order("x"), vec![1, 2, 0]);
}
#[test]
fn a_node_is_not_stale_merely_for_having_been_asked_earlier() {
let t = HealthTracker::new(
2,
HealthPolicy {
stale_block_threshold: 2,
block_interval: Duration::from_millis(10),
head_block_ttl: Duration::from_secs(10),
..Default::default()
},
);
t.observe_head_block(0, 100);
std::thread::sleep(Duration::from_millis(50));
t.observe_head_block(1, 105);
let report = t.snapshot();
assert!(
!report[0].stale,
"node 0 is current; it was just observed earlier: {report:?}"
);
assert_eq!(t.order("x"), vec![0, 1], "and so it keeps its place");
}
#[test]
fn a_node_that_is_genuinely_behind_is_still_caught() {
let t = HealthTracker::new(
2,
HealthPolicy {
stale_block_threshold: 2,
block_interval: Duration::from_millis(10),
..Default::default()
},
);
t.observe_head_block(0, 100);
t.observe_head_block(1, 500);
assert!(t.snapshot()[0].stale, "400 blocks behind is behind");
assert_eq!(t.order("x"), vec![1, 0]);
}
#[test]
fn a_behind_node_with_an_old_observation_is_still_caught() {
let t = HealthTracker::new(
2,
HealthPolicy {
stale_block_threshold: 2,
block_interval: Duration::from_millis(10),
head_block_ttl: Duration::from_secs(10),
..Default::default()
},
);
t.observe_head_block(0, 100);
std::thread::sleep(Duration::from_millis(50));
t.observe_head_block(1, 500);
let report = t.snapshot();
assert!(
report[0].stale,
"five blocks of credit does not close a 400-block gap: {report:?}"
);
assert!(
!report[1].stale,
"the current node must not be the stale one"
);
assert_eq!(t.order("x"), vec![1, 0]);
}
#[test]
fn the_snapshot_reports_the_head_as_observed_not_as_projected() {
let t = HealthTracker::new(1, policy());
t.observe_head_block(0, 12_345);
std::thread::sleep(Duration::from_millis(20));
assert_eq!(t.snapshot()[0].head_block, Some(12_345));
}
#[test]
fn being_slightly_behind_is_not_stale() {
let t = HealthTracker::new(2, policy());
t.observe_head_block(0, 1_090);
t.observe_head_block(1, 1_100);
assert_eq!(
t.order("x"),
vec![0, 1],
"10 blocks is within the threshold"
);
}
#[test]
fn nothing_is_stale_when_no_head_block_was_ever_observed() {
let t = HealthTracker::new(3, policy());
assert_eq!(t.order("x"), vec![0, 1, 2]);
assert!(t
.snapshot()
.iter()
.all(|h| !h.stale && h.head_block.is_none()));
}
#[test]
fn a_cooling_node_sorts_after_a_merely_stale_one() {
let t = HealthTracker::new(2, policy());
t.observe_head_block(0, 1_000);
t.observe_head_block(1, 1_100);
t.record_failure(1, "x");
t.record_failure(1, "x");
assert_eq!(t.order("x"), vec![0, 1]);
}
#[test]
fn every_node_is_still_tried_when_all_of_them_are_cooling() {
let t = HealthTracker::new(3, policy());
for i in 0..3 {
t.record_failure(i, "x");
t.record_failure(i, "x");
}
let mut order = t.order("x");
assert_eq!(order.len(), 3, "no node may be dropped from the order");
order.sort();
assert_eq!(order, vec![0, 1, 2]);
}
#[test]
fn a_cooldown_expires() {
let t = HealthTracker::new(
2,
HealthPolicy {
failures_before_cooldown: 1,
node_cooldown: Duration::from_millis(20),
..Default::default()
},
);
t.record_failure(0, "x");
t.record_failure(0, "y");
assert!(
t.snapshot()[0].in_cooldown,
"cooling immediately after failing"
);
assert_eq!(t.order("z"), vec![1, 0]);
std::thread::sleep(Duration::from_millis(50));
assert!(
!t.snapshot()[0].in_cooldown,
"and healthy again once it expires"
);
assert_eq!(t.order("z"), vec![0, 1]);
}
#[test]
fn a_stale_head_observation_is_ignored_rather_than_believed() {
let t = HealthTracker::new(
2,
HealthPolicy {
head_block_ttl: Duration::from_millis(20),
..Default::default()
},
);
t.observe_head_block(0, 1_000);
t.observe_head_block(1, 1_100);
assert_eq!(t.order("x"), vec![1, 0]);
std::thread::sleep(Duration::from_millis(50));
assert_eq!(t.order("x"), vec![0, 1], "the observations have expired");
}
#[test]
fn the_snapshot_reports_why_a_node_is_skipped() {
let t = HealthTracker::new(2, policy());
t.record_failure(0, "database_api.get_accounts");
t.record_failure(0, "database_api.get_accounts");
t.observe_head_block(0, 1_000);
t.observe_head_block(1, 2_000);
let s = t.snapshot();
assert_eq!(s[0].consecutive_failures, 2);
assert!(
!s[0].in_cooldown,
"one failing method is not a broadly broken node"
);
assert_eq!(s[0].cooling_methods, vec!["database_api.get_accounts"]);
assert_eq!(s[0].head_block, Some(1_000));
assert!(s[0].stale);
assert_eq!(s[1].consecutive_failures, 0);
assert!(!s[1].in_cooldown && !s[1].stale);
}
#[test]
fn one_failing_method_never_cools_the_whole_node() {
let t = HealthTracker::new(2, policy());
for _ in 0..20 {
t.record_failure(0, "account_history_api.get_ops_in_block");
}
let s = t.snapshot();
assert_eq!(s[0].consecutive_failures, 20);
assert!(!s[0].in_cooldown, "still not a whole-node fault");
assert_eq!(
t.order("database_api.get_accounts"),
vec![0, 1],
"an unaffected method must still prefer this node"
);
assert_eq!(
t.order("account_history_api.get_ops_in_block"),
vec![1, 0],
"the affected method must not"
);
}
#[test]
fn failing_across_methods_does_cool_the_whole_node() {
let t = HealthTracker::new(2, policy());
t.record_failure(0, "database_api.get_accounts");
t.record_failure(0, "account_history_api.get_ops_in_block");
let s = t.snapshot();
assert!(
s[0].in_cooldown,
"two different methods failing is a broken node: {s:?}"
);
assert_eq!(
t.order("some_other_api.thing"),
vec![1, 0],
"a method it has never failed must still avoid it"
);
}
#[test]
fn a_success_ends_the_streak_so_old_methods_do_not_accumulate() {
let t = HealthTracker::new(2, policy());
t.record_failure(0, "a.one");
t.record_success(0, "a.one");
t.record_failure(0, "b.two");
assert!(
!t.snapshot()[0].in_cooldown,
"the streak was broken by a success"
);
}
#[test]
fn out_of_range_indices_are_ignored_rather_than_panicking() {
let t = HealthTracker::new(1, policy());
t.record_failure(99, "x");
t.record_success(99, "x");
t.observe_head_block(99, 1);
assert_eq!(t.order("x"), vec![0]);
}
#[test]
fn head_block_is_read_from_a_dynamic_global_properties_response() {
let v = serde_json::json!({"head_block_number": 109_242_605u64, "time": "x"});
assert_eq!(head_block_of(&v), Some(109_242_605));
assert_eq!(head_block_of(&serde_json::json!({"other": 1})), None);
assert_eq!(head_block_of(&serde_json::json!(42)), None);
}
}