dora_node_api/event_stream/
input_tracker.rs1use std::collections::HashMap;
2
3use dora_arrow_convert::ArrowData;
4use dora_core::config::{DataId, NodeId};
5
6use super::event::Event;
7
8pub struct InputTracker {
45 states: HashMap<DataId, InputState>,
46 cache: HashMap<DataId, ArrowData>,
47 source_map: HashMap<DataId, NodeId>,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum InputState {
58 Healthy,
60 Closed,
62}
63
64impl InputTracker {
65 pub fn new() -> Self {
74 Self {
75 states: HashMap::new(),
76 cache: HashMap::new(),
77 source_map: HashMap::new(),
78 }
79 }
80
81 pub fn with_source_map(source_map: HashMap<DataId, NodeId>) -> Self {
89 Self {
90 states: HashMap::new(),
91 cache: HashMap::new(),
92 source_map,
93 }
94 }
95
96 pub fn process_event(&mut self, event: &Event) -> bool {
100 match event {
101 Event::Input { id, data, .. } => {
102 self.states.insert(id.clone(), InputState::Healthy);
103 self.cache.insert(id.clone(), ArrowData(data.0.clone()));
104 true
105 }
106 Event::InputClosed { id } => {
107 self.states.insert(id.clone(), InputState::Closed);
108 true
110 }
111 Event::InputRecovered { id } => {
112 self.states.insert(id.clone(), InputState::Healthy);
113 true
114 }
115 Event::NodeRestarted { id: restarted } => {
116 for (input_id, source) in &self.source_map {
121 if source == restarted && self.states.get(input_id) == Some(&InputState::Closed)
122 {
123 self.states.insert(input_id.clone(), InputState::Healthy);
124 }
125 }
126 true
130 }
131 _ => false,
132 }
133 }
134
135 pub fn state(&self, id: &DataId) -> Option<InputState> {
137 self.states.get(id).copied()
138 }
139
140 pub fn is_closed(&self, id: &DataId) -> bool {
142 self.states.get(id) == Some(&InputState::Closed)
143 }
144
145 pub fn last_value(&self, id: &DataId) -> Option<&ArrowData> {
147 self.cache.get(id)
148 }
149
150 pub fn closed_inputs(&self) -> Vec<&DataId> {
152 self.states
153 .iter()
154 .filter(|(_, s)| **s == InputState::Closed)
155 .map(|(id, _)| id)
156 .collect()
157 }
158
159 pub fn any_closed(&self) -> bool {
161 self.states.values().any(|s| *s == InputState::Closed)
162 }
163}
164
165impl Default for InputTracker {
166 fn default() -> Self {
167 Self::new()
168 }
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174 use arrow::array::new_empty_array;
175 use arrow::datatypes::DataType;
176 use dora_message::metadata::Metadata;
177
178 fn empty_data() -> ArrowData {
179 ArrowData(new_empty_array(&DataType::Null))
180 }
181
182 fn test_metadata() -> Metadata {
183 Metadata::new(dora_core::uhlc::HLC::default().new_timestamp())
184 }
185
186 fn make_input(id: &str, data: ArrowData) -> Event {
187 Event::Input {
188 id: id.into(),
189 metadata: test_metadata(),
190 data,
191 }
192 }
193
194 #[test]
195 fn tracks_healthy_input() {
196 let mut t = InputTracker::new();
197 assert!(t.process_event(&make_input("a", empty_data())));
198 assert_eq!(t.state(&"a".into()), Some(InputState::Healthy));
199 assert!(!t.is_closed(&"a".into()));
200 assert!(t.last_value(&"a".into()).is_some());
201 }
202
203 #[test]
204 fn tracks_closed_preserves_cache() {
205 let mut t = InputTracker::new();
206 t.process_event(&make_input("a", empty_data()));
207 t.process_event(&Event::InputClosed { id: "a".into() });
208
209 assert_eq!(t.state(&"a".into()), Some(InputState::Closed));
210 assert!(t.is_closed(&"a".into()));
211 assert!(t.last_value(&"a".into()).is_some());
212 assert!(t.any_closed());
213 assert_eq!(t.closed_inputs().len(), 1);
214 }
215
216 #[test]
217 fn tracks_recovery() {
218 let mut t = InputTracker::new();
219 t.process_event(&make_input("a", empty_data()));
220 t.process_event(&Event::InputClosed { id: "a".into() });
221 t.process_event(&Event::InputRecovered { id: "a".into() });
222
223 assert_eq!(t.state(&"a".into()), Some(InputState::Healthy));
224 assert!(!t.any_closed());
225 }
226
227 #[test]
228 fn ignores_irrelevant_events() {
229 let mut t = InputTracker::new();
230 assert!(!t.process_event(&Event::Stop(super::super::event::StopCause::Manual)));
231 }
232
233 #[test]
236 fn node_restarted_without_source_map_is_acknowledged_but_noop() {
237 let mut t = InputTracker::new();
242 t.process_event(&make_input("a", empty_data()));
243 t.process_event(&Event::InputClosed { id: "a".into() });
244 assert!(t.is_closed(&"a".into()));
245
246 let relevant = t.process_event(&Event::NodeRestarted {
247 id: NodeId::from("upstream".to_string()),
248 });
249 assert!(relevant, "NodeRestarted should be reported as relevant");
250 assert!(t.is_closed(&"a".into()));
252 }
253
254 #[test]
255 fn node_restarted_with_source_map_recovers_matching_closed_inputs() {
256 let mut source_map = HashMap::new();
257 source_map.insert(
258 DataId::from("sensor".to_string()),
259 NodeId::from("camera".to_string()),
260 );
261 source_map.insert(
262 DataId::from("telemetry".to_string()),
263 NodeId::from("camera".to_string()),
264 );
265 source_map.insert(
266 DataId::from("config".to_string()),
267 NodeId::from("other".to_string()),
268 );
269 let mut t = InputTracker::with_source_map(source_map);
270
271 t.process_event(&Event::InputClosed {
273 id: "sensor".into(),
274 });
275 t.process_event(&Event::InputClosed {
276 id: "telemetry".into(),
277 });
278 t.process_event(&Event::InputClosed {
279 id: "config".into(),
280 });
281 assert_eq!(t.closed_inputs().len(), 3);
282
283 assert!(t.process_event(&Event::NodeRestarted {
285 id: NodeId::from("camera".to_string()),
286 }));
287
288 assert_eq!(t.state(&"sensor".into()), Some(InputState::Healthy));
289 assert_eq!(t.state(&"telemetry".into()), Some(InputState::Healthy));
290 assert_eq!(t.state(&"config".into()), Some(InputState::Closed));
291 }
292
293 #[test]
294 fn node_restarted_preserves_last_value_cache() {
295 let mut source_map = HashMap::new();
298 source_map.insert(
299 DataId::from("sensor".to_string()),
300 NodeId::from("camera".to_string()),
301 );
302 let mut t = InputTracker::with_source_map(source_map);
303
304 t.process_event(&make_input("sensor", empty_data()));
305 t.process_event(&Event::InputClosed {
306 id: "sensor".into(),
307 });
308 assert!(t.last_value(&"sensor".into()).is_some());
309
310 t.process_event(&Event::NodeRestarted {
311 id: NodeId::from("camera".to_string()),
312 });
313
314 assert_eq!(t.state(&"sensor".into()), Some(InputState::Healthy));
315 assert!(
316 t.last_value(&"sensor".into()).is_some(),
317 "cached value should survive restart so nodes can degrade gracefully"
318 );
319 }
320
321 #[test]
322 fn node_restarted_leaves_healthy_inputs_alone() {
323 let mut source_map = HashMap::new();
325 source_map.insert(
326 DataId::from("sensor".to_string()),
327 NodeId::from("camera".to_string()),
328 );
329 let mut t = InputTracker::with_source_map(source_map);
330
331 t.process_event(&make_input("sensor", empty_data()));
332 assert_eq!(t.state(&"sensor".into()), Some(InputState::Healthy));
333
334 t.process_event(&Event::NodeRestarted {
335 id: NodeId::from("camera".to_string()),
336 });
337 assert_eq!(t.state(&"sensor".into()), Some(InputState::Healthy));
338 }
339}