use std::collections::HashMap;
use dora_arrow_convert::ArrowData;
use dora_core::config::{DataId, NodeId};
use super::event::Event;
pub struct InputTracker {
states: HashMap<DataId, InputState>,
cache: HashMap<DataId, ArrowData>,
source_map: HashMap<DataId, NodeId>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InputState {
Healthy,
Closed,
}
impl InputTracker {
pub fn new() -> Self {
Self {
states: HashMap::new(),
cache: HashMap::new(),
source_map: HashMap::new(),
}
}
pub fn with_source_map(source_map: HashMap<DataId, NodeId>) -> Self {
Self {
states: HashMap::new(),
cache: HashMap::new(),
source_map,
}
}
pub fn process_event(&mut self, event: &Event) -> bool {
match event {
Event::Input { id, data, .. } => {
self.states.insert(id.clone(), InputState::Healthy);
self.cache.insert(id.clone(), ArrowData(data.0.clone()));
true
}
Event::InputClosed { id } => {
self.states.insert(id.clone(), InputState::Closed);
true
}
Event::InputRecovered { id } => {
self.states.insert(id.clone(), InputState::Healthy);
true
}
Event::NodeRestarted { id: restarted } => {
for (input_id, source) in &self.source_map {
if source == restarted && self.states.get(input_id) == Some(&InputState::Closed)
{
self.states.insert(input_id.clone(), InputState::Healthy);
}
}
true
}
_ => false,
}
}
pub fn state(&self, id: &DataId) -> Option<InputState> {
self.states.get(id).copied()
}
pub fn is_closed(&self, id: &DataId) -> bool {
self.states.get(id) == Some(&InputState::Closed)
}
pub fn last_value(&self, id: &DataId) -> Option<&ArrowData> {
self.cache.get(id)
}
pub fn closed_inputs(&self) -> Vec<&DataId> {
self.states
.iter()
.filter(|(_, s)| **s == InputState::Closed)
.map(|(id, _)| id)
.collect()
}
pub fn any_closed(&self) -> bool {
self.states.values().any(|s| *s == InputState::Closed)
}
}
impl Default for InputTracker {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use arrow::array::new_empty_array;
use arrow::datatypes::DataType;
use dora_message::metadata::Metadata;
fn empty_data() -> ArrowData {
ArrowData(new_empty_array(&DataType::Null))
}
fn test_metadata() -> Metadata {
Metadata::new(dora_core::uhlc::HLC::default().new_timestamp())
}
fn make_input(id: &str, data: ArrowData) -> Event {
Event::Input {
id: id.into(),
metadata: test_metadata(),
data,
}
}
#[test]
fn tracks_healthy_input() {
let mut t = InputTracker::new();
assert!(t.process_event(&make_input("a", empty_data())));
assert_eq!(t.state(&"a".into()), Some(InputState::Healthy));
assert!(!t.is_closed(&"a".into()));
assert!(t.last_value(&"a".into()).is_some());
}
#[test]
fn tracks_closed_preserves_cache() {
let mut t = InputTracker::new();
t.process_event(&make_input("a", empty_data()));
t.process_event(&Event::InputClosed { id: "a".into() });
assert_eq!(t.state(&"a".into()), Some(InputState::Closed));
assert!(t.is_closed(&"a".into()));
assert!(t.last_value(&"a".into()).is_some());
assert!(t.any_closed());
assert_eq!(t.closed_inputs().len(), 1);
}
#[test]
fn tracks_recovery() {
let mut t = InputTracker::new();
t.process_event(&make_input("a", empty_data()));
t.process_event(&Event::InputClosed { id: "a".into() });
t.process_event(&Event::InputRecovered { id: "a".into() });
assert_eq!(t.state(&"a".into()), Some(InputState::Healthy));
assert!(!t.any_closed());
}
#[test]
fn ignores_irrelevant_events() {
let mut t = InputTracker::new();
assert!(!t.process_event(&Event::Stop(super::super::event::StopCause::Manual)));
}
#[test]
fn node_restarted_without_source_map_is_acknowledged_but_noop() {
let mut t = InputTracker::new();
t.process_event(&make_input("a", empty_data()));
t.process_event(&Event::InputClosed { id: "a".into() });
assert!(t.is_closed(&"a".into()));
let relevant = t.process_event(&Event::NodeRestarted {
id: NodeId::from("upstream".to_string()),
});
assert!(relevant, "NodeRestarted should be reported as relevant");
assert!(t.is_closed(&"a".into()));
}
#[test]
fn node_restarted_with_source_map_recovers_matching_closed_inputs() {
let mut source_map = HashMap::new();
source_map.insert(
DataId::from("sensor".to_string()),
NodeId::from("camera".to_string()),
);
source_map.insert(
DataId::from("telemetry".to_string()),
NodeId::from("camera".to_string()),
);
source_map.insert(
DataId::from("config".to_string()),
NodeId::from("other".to_string()),
);
let mut t = InputTracker::with_source_map(source_map);
t.process_event(&Event::InputClosed {
id: "sensor".into(),
});
t.process_event(&Event::InputClosed {
id: "telemetry".into(),
});
t.process_event(&Event::InputClosed {
id: "config".into(),
});
assert_eq!(t.closed_inputs().len(), 3);
assert!(t.process_event(&Event::NodeRestarted {
id: NodeId::from("camera".to_string()),
}));
assert_eq!(t.state(&"sensor".into()), Some(InputState::Healthy));
assert_eq!(t.state(&"telemetry".into()), Some(InputState::Healthy));
assert_eq!(t.state(&"config".into()), Some(InputState::Closed));
}
#[test]
fn node_restarted_preserves_last_value_cache() {
let mut source_map = HashMap::new();
source_map.insert(
DataId::from("sensor".to_string()),
NodeId::from("camera".to_string()),
);
let mut t = InputTracker::with_source_map(source_map);
t.process_event(&make_input("sensor", empty_data()));
t.process_event(&Event::InputClosed {
id: "sensor".into(),
});
assert!(t.last_value(&"sensor".into()).is_some());
t.process_event(&Event::NodeRestarted {
id: NodeId::from("camera".to_string()),
});
assert_eq!(t.state(&"sensor".into()), Some(InputState::Healthy));
assert!(
t.last_value(&"sensor".into()).is_some(),
"cached value should survive restart so nodes can degrade gracefully"
);
}
#[test]
fn node_restarted_leaves_healthy_inputs_alone() {
let mut source_map = HashMap::new();
source_map.insert(
DataId::from("sensor".to_string()),
NodeId::from("camera".to_string()),
);
let mut t = InputTracker::with_source_map(source_map);
t.process_event(&make_input("sensor", empty_data()));
assert_eq!(t.state(&"sensor".into()), Some(InputState::Healthy));
t.process_event(&Event::NodeRestarted {
id: NodeId::from("camera".to_string()),
});
assert_eq!(t.state(&"sensor".into()), Some(InputState::Healthy));
}
}