use std::collections::{HashMap, HashSet};
use std::hash::Hash;
use openlogi_core::device::DeviceInventory;
use tracing::{debug, warn};
const NODE_MISS_GRACE: u8 = 3;
const CHANNEL_EVICT_AFTER: u8 = 2;
pub(crate) struct SettledNode {
pub inventory: Option<DeviceInventory>,
pub evict_channel: bool,
}
pub(crate) struct NodeLedger<K> {
last_good: HashMap<K, DeviceInventory>,
failures: HashMap<K, u8>,
}
impl<K> Default for NodeLedger<K> {
fn default() -> Self {
Self {
last_good: HashMap::new(),
failures: HashMap::new(),
}
}
}
impl<K: Eq + Hash + Clone> NodeLedger<K> {
pub fn settle(
&mut self,
node: &K,
healthy: bool,
live: Option<DeviceInventory>,
) -> SettledNode {
if healthy {
self.failures.remove(node);
let inventory = if let Some(inv) = live {
self.last_good.insert(node.clone(), inv.clone());
Some(inv)
} else {
self.last_good.remove(node);
None
};
return SettledNode {
inventory,
evict_channel: false,
};
}
let failures = self.failures.entry(node.clone()).or_insert(0);
*failures = failures.saturating_add(1);
let failures = *failures;
let inventory = match self.last_good.get(node) {
Some(prev) if failures <= NODE_MISS_GRACE => {
debug!(
failures,
"node probe failed — replaying its last good inventory"
);
Some(prev.clone())
}
_ => {
if self.last_good.remove(node).is_some() {
warn!(
failures,
"node probe failures exhausted the replay grace — surfacing the live result"
);
}
live
}
};
SettledNode {
inventory,
evict_channel: failures >= CHANNEL_EVICT_AFTER,
}
}
pub fn retain_nodes(&mut self, seen: &HashSet<K>) {
self.last_good.retain(|node, _| seen.contains(node));
self.failures.retain(|node, _| seen.contains(node));
}
}
#[cfg(test)]
mod tests {
use openlogi_core::device::{DeviceInventory, ReceiverInfo};
use super::{CHANNEL_EVICT_AFTER, NODE_MISS_GRACE, NodeLedger};
fn inventory(name: &str) -> DeviceInventory {
DeviceInventory {
receiver: ReceiverInfo {
name: name.to_string(),
vendor_id: 0x046d,
product_id: 0xc548,
unique_id: None,
},
paired: Vec::new(),
}
}
fn receiver_name(inv: Option<&DeviceInventory>) -> Option<&str> {
inv.map(|i| i.receiver.name.as_str())
}
#[test]
fn failed_probe_replays_the_last_good_inventory_within_grace() {
let mut ledger = NodeLedger::default();
ledger.settle(&1, true, Some(inventory("bolt")));
for _ in 0..NODE_MISS_GRACE {
let settled = ledger.settle(&1, false, None);
assert_eq!(receiver_name(settled.inventory.as_ref()), Some("bolt"));
}
}
#[test]
fn replay_grace_expires_to_the_live_result() {
let mut ledger = NodeLedger::default();
ledger.settle(&1, true, Some(inventory("bolt")));
for _ in 0..NODE_MISS_GRACE {
ledger.settle(&1, false, None);
}
let expired = ledger.settle(&1, false, Some(inventory("partial")));
assert_eq!(receiver_name(expired.inventory.as_ref()), Some("partial"));
let after = ledger.settle(&1, false, None);
assert!(after.inventory.is_none());
}
#[test]
fn a_healthy_tick_resets_the_failure_count() {
let mut ledger = NodeLedger::default();
ledger.settle(&1, true, Some(inventory("bolt")));
for _ in 0..NODE_MISS_GRACE {
ledger.settle(&1, false, None);
}
ledger.settle(&1, true, Some(inventory("bolt")));
let settled = ledger.settle(&1, false, None);
assert_eq!(
receiver_name(settled.inventory.as_ref()),
Some("bolt"),
"the recovery should re-arm the full replay grace"
);
}
#[test]
fn persistent_failure_keeps_requesting_channel_eviction() {
let mut ledger = NodeLedger::default();
ledger.settle(&1, true, Some(inventory("bolt")));
for i in 1..=NODE_MISS_GRACE + 2 {
let settled = ledger.settle(&1, false, None);
assert_eq!(
settled.evict_channel,
i >= CHANNEL_EVICT_AFTER,
"tick {i}: eviction starts at the threshold and keeps firing"
);
}
let recovered = ledger.settle(&1, true, Some(inventory("bolt")));
assert!(!recovered.evict_channel);
}
#[test]
fn a_healthy_empty_result_clears_the_replay_state() {
let mut ledger = NodeLedger::default();
ledger.settle(&1, true, Some(inventory("bolt")));
ledger.settle(&1, true, None);
let settled = ledger.settle(&1, false, None);
assert!(settled.inventory.is_none());
}
#[test]
fn vanished_nodes_are_dropped_from_the_ledger() {
let mut ledger = NodeLedger::default();
ledger.settle(&1, true, Some(inventory("kept")));
ledger.settle(&2, true, Some(inventory("gone")));
ledger.retain_nodes(&std::iter::once(1).collect());
let replayed = ledger.settle(&1, false, None);
assert_eq!(receiver_name(replayed.inventory.as_ref()), Some("kept"));
let dropped = ledger.settle(&2, false, None);
assert!(
dropped.inventory.is_none(),
"a reappeared node starts clean"
);
}
}