#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub enum HostState {
Unknown,
Healthy,
TransientReject,
TransportError,
TopologyReject,
}
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub enum ZoneTier {
Same,
Unknown,
Other,
}
pub struct HostHealthTracker {
states: Vec<HostState>,
zone_tiers: Vec<ZoneTier>,
attempted_this_round: Vec<bool>,
last_success_epoch: Vec<u64>,
next_success_epoch: u64,
configured_zone: Option<String>,
target_primary: bool,
}
impl HostHealthTracker {
pub fn new(host_count: usize, client_zone: Option<&str>, target_primary: bool) -> Self {
assert!(host_count > 0, "host_count must be > 0");
let configured_zone = client_zone
.map(str::trim)
.filter(|z| !z.is_empty())
.map(str::to_ascii_lowercase);
let initial_tier = if configured_zone.is_none() || target_primary {
ZoneTier::Same
} else {
ZoneTier::Unknown
};
Self {
states: vec![HostState::Unknown; host_count],
zone_tiers: vec![initial_tier; host_count],
attempted_this_round: vec![false; host_count],
last_success_epoch: vec![0; host_count],
next_success_epoch: 0,
configured_zone,
target_primary,
}
}
#[cfg(test)]
pub(crate) fn state(&self, idx: usize) -> HostState {
self.states[idx]
}
#[cfg(test)]
pub(crate) fn zone_tier(&self, idx: usize) -> ZoneTier {
self.zone_tiers[idx]
}
#[cfg(test)]
pub(crate) fn is_round_exhausted(&self) -> bool {
self.attempted_this_round.iter().all(|a| *a)
}
pub fn pick_next(&self) -> Option<usize> {
const STATES: [HostState; 5] = [
HostState::Healthy,
HostState::Unknown,
HostState::TransientReject,
HostState::TransportError,
HostState::TopologyReject,
];
const ZONES: [ZoneTier; 3] = [ZoneTier::Same, ZoneTier::Unknown, ZoneTier::Other];
for state in STATES {
for zone in ZONES {
for (i, _) in self.states.iter().enumerate() {
if !self.attempted_this_round[i]
&& self.states[i] == state
&& self.zone_tiers[i] == zone
{
return Some(i);
}
}
}
}
None
}
pub fn record_success(&mut self, idx: usize) {
self.states[idx] = HostState::Healthy;
self.attempted_this_round[idx] = true;
self.next_success_epoch += 1;
self.last_success_epoch[idx] = self.next_success_epoch;
}
pub fn record_role_reject(&mut self, idx: usize, transient: bool) {
self.states[idx] = if transient {
HostState::TransientReject
} else {
HostState::TopologyReject
};
self.attempted_this_round[idx] = true;
}
pub fn record_transport_error(&mut self, idx: usize) {
self.states[idx] = HostState::TransportError;
self.attempted_this_round[idx] = true;
}
pub fn record_mid_stream_failure(&mut self, idx: usize) {
if self.states[idx] == HostState::Healthy {
self.states[idx] = HostState::TransportError;
}
}
pub fn record_zone(&mut self, idx: usize, zone_id: Option<&str>) {
let raw = match zone_id {
Some(z) => z,
None => return,
};
let trimmed = raw.trim();
if trimmed.is_empty() {
return;
}
let tier = if self.configured_zone.is_none() || self.target_primary {
ZoneTier::Same
} else if let Some(cfg_zone) = self.configured_zone.as_deref()
&& trimmed.eq_ignore_ascii_case(cfg_zone)
{
ZoneTier::Same
} else {
ZoneTier::Other
};
self.zone_tiers[idx] = tier;
}
pub fn begin_round(&mut self, forget_classifications: bool) {
let mut sticky_index: Option<usize> = None;
if forget_classifications {
let mut best_epoch: u64 = 0;
for i in 0..self.states.len() {
if self.states[i] == HostState::Healthy
&& self.zone_tiers[i] == ZoneTier::Same
&& self.last_success_epoch[i] > best_epoch
{
best_epoch = self.last_success_epoch[i];
sticky_index = Some(i);
}
}
}
for i in 0..self.states.len() {
self.attempted_this_round[i] = false;
if forget_classifications && Some(i) != sticky_index {
self.states[i] = HostState::Unknown;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn t(n: usize) -> HostHealthTracker {
HostHealthTracker::new(n, None, false)
}
#[test]
fn fresh_tracker_picks_lowest_index() {
let t = t(3);
assert_eq!(t.pick_next(), Some(0));
}
#[test]
fn attempted_bits_block_repick_within_round() {
let mut t = t(3);
t.record_transport_error(0);
assert_eq!(t.pick_next(), Some(1));
t.record_transport_error(1);
assert_eq!(t.pick_next(), Some(2));
t.record_transport_error(2);
assert_eq!(t.pick_next(), None);
assert!(t.is_round_exhausted());
}
#[test]
fn priority_orders_state_before_zone() {
let mut t = HostHealthTracker::new(3, Some("eu-west"), false);
t.record_zone(0, Some("us-east")); t.record_zone(1, Some("eu-west")); t.record_zone(2, Some("eu-west")); t.record_success(0); t.begin_round(false);
assert_eq!(t.pick_next(), Some(0), "Healthy/Other beats Unknown/Same");
}
#[test]
fn priority_orders_zone_within_state() {
let mut t = HostHealthTracker::new(3, Some("eu-west"), false);
t.record_zone(0, Some("us-east")); t.record_zone(1, Some("eu-west")); assert_eq!(t.pick_next(), Some(1));
t.record_transport_error(1);
assert_eq!(t.pick_next(), Some(2));
t.record_transport_error(2);
assert_eq!(t.pick_next(), Some(0));
}
#[test]
fn record_role_reject_classifies_transient_vs_topological() {
let mut t = t(2);
t.record_role_reject(0, true);
t.record_role_reject(1, false);
assert_eq!(t.state(0), HostState::TransientReject);
assert_eq!(t.state(1), HostState::TopologyReject);
t.begin_round(false);
assert_eq!(t.pick_next(), Some(0));
}
#[test]
fn record_mid_stream_failure_only_demotes_healthy() {
let mut t = t(3);
t.record_success(0);
t.record_role_reject(1, false);
t.record_transport_error(2);
t.record_mid_stream_failure(0); t.record_mid_stream_failure(1); t.record_mid_stream_failure(2); assert_eq!(t.state(0), HostState::TransportError);
assert_eq!(t.state(1), HostState::TopologyReject);
assert_eq!(t.state(2), HostState::TransportError);
}
#[test]
fn record_mid_stream_failure_does_not_touch_attempted_bit() {
let mut t = t(2);
t.record_success(0);
t.record_mid_stream_failure(0);
assert_eq!(t.pick_next(), Some(1));
}
#[test]
fn begin_round_false_resets_attempted_only() {
let mut t = t(3);
t.record_transport_error(0);
t.record_role_reject(1, false);
t.record_success(2);
t.begin_round(false);
assert!(!t.is_round_exhausted());
assert_eq!(t.state(0), HostState::TransportError);
assert_eq!(t.state(1), HostState::TopologyReject);
assert_eq!(t.state(2), HostState::Healthy);
assert_eq!(t.pick_next(), Some(2));
}
#[test]
fn begin_round_true_forgets_non_healthy_keeps_sticky() {
let mut t = t(3);
t.record_transport_error(0);
t.record_role_reject(1, false);
t.record_success(2);
t.begin_round(true);
assert_eq!(t.state(0), HostState::Unknown);
assert_eq!(t.state(1), HostState::Unknown);
assert_eq!(t.state(2), HostState::Healthy);
assert_eq!(t.pick_next(), Some(2));
}
#[test]
fn sticky_healthy_keeps_most_recent_success_only() {
let mut t = t(3);
t.record_success(0); t.record_success(1); t.record_success(2); t.begin_round(true);
assert_eq!(t.state(0), HostState::Unknown);
assert_eq!(t.state(1), HostState::Unknown);
assert_eq!(t.state(2), HostState::Healthy);
}
#[test]
fn sticky_healthy_skips_cross_zone() {
let mut t = HostHealthTracker::new(3, Some("eu-west"), false);
t.record_zone(0, Some("us-east")); t.record_zone(1, Some("eu-west")); t.record_success(1); t.record_success(0); t.begin_round(true);
assert_eq!(
t.state(0),
HostState::Unknown,
"cross-zone Healthy must reset"
);
assert_eq!(
t.state(1),
HostState::Healthy,
"same-zone Healthy stays sticky"
);
}
#[test]
fn zone_tier_unset_when_zone_id_empty_or_missing() {
let mut t = HostHealthTracker::new(2, Some("eu-west"), false);
assert_eq!(t.zone_tier(0), ZoneTier::Unknown);
t.record_zone(0, None);
t.record_zone(0, Some(""));
t.record_zone(0, Some(" "));
assert_eq!(t.zone_tier(0), ZoneTier::Unknown);
t.record_zone(0, Some("EU-WEST")); assert_eq!(t.zone_tier(0), ZoneTier::Same);
}
#[test]
fn target_primary_collapses_zones_to_same() {
let mut t = HostHealthTracker::new(2, Some("eu-west"), true);
t.record_zone(0, Some("us-east"));
t.record_zone(1, Some("apac"));
assert_eq!(t.zone_tier(0), ZoneTier::Same);
assert_eq!(t.zone_tier(1), ZoneTier::Same);
}
#[test]
fn zone_tier_survives_begin_round_true() {
let mut t = HostHealthTracker::new(2, Some("eu-west"), false);
t.record_zone(0, Some("us-east")); t.record_zone(1, Some("eu-west")); t.record_role_reject(0, false);
t.record_role_reject(1, false);
t.begin_round(true);
assert_eq!(t.zone_tier(0), ZoneTier::Other);
assert_eq!(t.zone_tier(1), ZoneTier::Same);
}
#[test]
fn unset_client_zone_collapses_to_same() {
let mut t = HostHealthTracker::new(2, None, false);
t.record_zone(0, Some("us-east"));
t.record_zone(1, Some("anywhere"));
assert_eq!(t.zone_tier(0), ZoneTier::Same);
assert_eq!(t.zone_tier(1), ZoneTier::Same);
}
#[test]
fn empty_client_zone_collapses_to_same() {
let mut t = HostHealthTracker::new(2, Some(" "), false);
t.record_zone(0, Some("us-east"));
assert_eq!(t.zone_tier(0), ZoneTier::Same);
}
#[test]
fn priority_lattice_full_order() {
let mut t = t(5);
t.record_success(0); t.record_role_reject(2, true); t.record_transport_error(3); t.record_role_reject(4, false); t.begin_round(false);
let mut order = Vec::new();
while let Some(i) = t.pick_next() {
order.push(i);
t.attempted_this_round[i] = true;
}
assert_eq!(order, vec![0, 1, 2, 3, 4]);
}
#[test]
fn pick_next_returns_none_when_all_attempted() {
let mut t = t(2);
t.record_transport_error(0);
t.record_transport_error(1);
assert!(t.is_round_exhausted());
assert_eq!(t.pick_next(), None);
}
#[test]
fn round_exhausted_then_begin_round_unlocks_picks() {
let mut t = t(2);
t.record_transport_error(0);
t.record_transport_error(1);
assert_eq!(t.pick_next(), None);
t.begin_round(false); assert_eq!(t.pick_next(), Some(0));
}
}