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;
const ARRIVAL_REPLAY_EVICT_AFTER: u8 = NODE_MISS_GRACE + 1;
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, FailureCounts>,
}
#[derive(Default)]
struct FailureCounts {
probe: u8,
arrival_replay: u8,
publication_misses: u8,
}
#[derive(Clone, Copy)]
enum FailureKind {
Probe,
ArrivalReplay,
}
impl FailureKind {
fn record(self, counts: &mut FailureCounts) -> u8 {
match self {
Self::Probe => {
counts.probe = counts.probe.saturating_add(1);
counts.probe
}
Self::ArrivalReplay => {
counts.probe = 0;
counts.arrival_replay = counts.arrival_replay.saturating_add(1);
counts.arrival_replay
}
}
}
const fn label(self) -> &'static str {
match self {
Self::Probe => "probe",
Self::ArrivalReplay => "arrival replay",
}
}
}
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 reset_channel_failures_after_open(&mut self, node: &K) {
if let Some(counts) = self.failures.get_mut(node) {
counts.probe = 0;
counts.arrival_replay = 0;
}
}
pub fn settle_arrival_replay_failure(&mut self, node: &K) -> SettledNode {
self.settle_failure(
node,
None,
ARRIVAL_REPLAY_EVICT_AFTER,
FailureKind::ArrivalReplay,
)
}
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,
};
}
self.settle_failure(node, live, CHANNEL_EVICT_AFTER, FailureKind::Probe)
}
fn settle_failure(
&mut self,
node: &K,
live: Option<DeviceInventory>,
evict_after: u8,
failure_kind: FailureKind,
) -> SettledNode {
let counts = self.failures.entry(node.clone()).or_default();
let channel_failures = failure_kind.record(counts);
counts.publication_misses = counts.publication_misses.saturating_add(1);
let publication_misses = counts.publication_misses;
let failure_kind = failure_kind.label();
let inventory = if publication_misses <= NODE_MISS_GRACE {
if let Some(previous) = self.last_good.get(node) {
debug!(
failures = publication_misses,
channel_failures,
failure_kind,
"node check incomplete — replaying its last good inventory"
);
Some(previous.clone())
} else {
live
}
} else {
if self.last_good.remove(node).is_some() {
warn!(
failures = publication_misses,
channel_failures,
failure_kind,
"node check failures exhausted the replay grace — surfacing the live result"
);
}
live
};
SettledNode {
inventory,
evict_channel: channel_failures >= evict_after,
}
}
pub fn defer(&self, node: &K) -> SettledNode {
SettledNode {
inventory: self.last_good.get(node).cloned(),
evict_channel: false,
}
}
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)]
pub(super) fn contains_last_good(&self, node: &K) -> bool {
self.last_good.contains_key(node)
}
}
#[cfg(test)]
mod tests {
use openlogi_core::device::{DeviceInventory, ReceiverInfo};
use super::{ARRIVAL_REPLAY_EVICT_AFTER, 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 transient_arrival_replay_failures_keep_the_channel_and_snapshot() {
let mut ledger = NodeLedger::default();
ledger.settle(&1, true, Some(inventory("unifying")));
for _ in 1..ARRIVAL_REPLAY_EVICT_AFTER {
let retained = ledger.settle_arrival_replay_failure(&1);
assert_eq!(receiver_name(retained.inventory.as_ref()), Some("unifying"));
assert!(!retained.evict_channel);
}
}
#[test]
fn persistent_arrival_replay_failure_retires_the_channel_and_snapshot() {
let mut ledger = NodeLedger::default();
ledger.settle(&1, true, Some(inventory("unifying")));
for _ in 1..ARRIVAL_REPLAY_EVICT_AFTER {
ledger.settle_arrival_replay_failure(&1);
}
let exhausted = ledger.settle_arrival_replay_failure(&1);
assert!(exhausted.evict_channel);
assert!(exhausted.inventory.is_none());
}
#[test]
fn expired_arrival_snapshot_does_not_resurface_on_probe_failure() {
let mut ledger = NodeLedger::default();
ledger.settle(&1, true, Some(inventory("unifying")));
for _ in 0..ARRIVAL_REPLAY_EVICT_AFTER {
ledger.settle_arrival_replay_failure(&1);
}
let retiring = ledger.settle(&1, false, None);
assert!(retiring.inventory.is_none());
assert!(!retiring.evict_channel);
}
#[test]
fn complete_probe_resets_the_arrival_replay_failure_streak() {
let mut ledger = NodeLedger::default();
ledger.settle(&1, true, Some(inventory("unifying")));
for _ in 1..ARRIVAL_REPLAY_EVICT_AFTER {
ledger.settle_arrival_replay_failure(&1);
}
ledger.settle(&1, true, Some(inventory("unifying")));
let retained = ledger.settle_arrival_replay_failure(&1);
assert!(!retained.evict_channel);
assert_eq!(receiver_name(retained.inventory.as_ref()), Some("unifying"));
}
#[test]
fn one_probe_timeout_does_not_inherit_arrival_replay_failures() {
let mut ledger = NodeLedger::default();
ledger.settle(&1, true, Some(inventory("unifying")));
ledger.settle_arrival_replay_failure(&1);
ledger.settle_arrival_replay_failure(&1);
let first_probe_timeout = ledger.settle(&1, false, None);
assert!(!first_probe_timeout.evict_channel);
assert_eq!(
receiver_name(first_probe_timeout.inventory.as_ref()),
Some("unifying")
);
assert!(
ledger.settle(&1, false, None).evict_channel,
"ordinary failures still retire on their own second consecutive tick"
);
}
#[test]
fn mixed_timeouts_do_not_hide_persistent_arrival_replay_failure() {
let mut ledger = NodeLedger::default();
ledger.settle(&1, true, Some(inventory("unifying")));
for _ in 1..ARRIVAL_REPLAY_EVICT_AFTER {
assert!(!ledger.settle_arrival_replay_failure(&1).evict_channel);
assert!(!ledger.settle(&1, false, None).evict_channel);
}
let exhausted = ledger.settle_arrival_replay_failure(&1);
assert!(exhausted.evict_channel);
assert!(exhausted.inventory.is_none());
}
#[test]
fn replacement_channel_gets_fresh_eviction_grace_without_resurrecting_inventory() {
let mut ledger = NodeLedger::default();
ledger.settle(&1, true, Some(inventory("unifying")));
for _ in 0..ARRIVAL_REPLAY_EVICT_AFTER - 1 {
ledger.settle_arrival_replay_failure(&1);
}
assert!(!ledger.settle(&1, false, None).evict_channel);
let retired = ledger.settle(&1, false, None);
assert!(retired.evict_channel);
assert!(retired.inventory.is_none());
for _ in 0..=NODE_MISS_GRACE {
ledger.settle(&1, false, None);
}
assert!(
ledger.settle(&1, false, None).inventory.is_none(),
"a long retirement must still stop publishing stale inventory"
);
ledger.reset_channel_failures_after_open(&1);
let replacement_failure = ledger.settle_arrival_replay_failure(&1);
assert!(!replacement_failure.evict_channel);
assert!(replacement_failure.inventory.is_none());
}
#[test]
fn repeated_channel_opens_do_not_rearm_inventory_replay() {
let mut ledger = NodeLedger::default();
ledger.settle(&1, true, Some(inventory("unifying")));
let mut published = Vec::new();
for _ in 0..3 {
ledger.reset_channel_failures_after_open(&1);
let first = ledger.settle(&1, false, None);
published.push(first.inventory.is_some());
assert!(!first.evict_channel);
let second = ledger.settle(&1, false, None);
published.push(second.inventory.is_some());
assert!(second.evict_channel);
}
assert_eq!(published, vec![true, true, true, false, false, false]);
}
#[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 one_failed_probe_never_evicts_but_keeps_the_devices_visible() {
let mut ledger = NodeLedger::default();
ledger.settle(&1, true, Some(inventory("bolt")));
let first = ledger.settle(&1, false, None);
assert!(!first.evict_channel);
assert_eq!(receiver_name(first.inventory.as_ref()), Some("bolt"));
assert!(
ledger.settle(&1, false, None).evict_channel,
"a node that keeps failing is still replaced on the next tick"
);
}
#[test]
fn deferred_ticks_replay_without_counting_toward_eviction() {
let mut ledger = NodeLedger::default();
ledger.settle(&1, true, Some(inventory("bolt")));
assert!(!ledger.settle(&1, false, None).evict_channel);
for _ in 0..NODE_MISS_GRACE + 2 {
let deferred = ledger.defer(&1);
assert!(!deferred.evict_channel, "a deferred tick never evicts");
assert_eq!(receiver_name(deferred.inventory.as_ref()), Some("bolt"));
}
assert!(
ledger.settle(&1, false, None).evict_channel,
"the second real failure still evicts: deferrals left the count at one"
);
}
#[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"
);
}
}