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 {
36 states: HashMap<DataId, InputState>,
37 cache: HashMap<DataId, ArrowData>,
38 source_map: HashMap<DataId, NodeId>,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum InputState {
49 Healthy,
51 Closed,
53}
54
55impl InputTracker {
56 pub fn new() -> Self {
65 Self {
66 states: HashMap::new(),
67 cache: HashMap::new(),
68 source_map: HashMap::new(),
69 }
70 }
71
72 pub fn with_source_map(source_map: HashMap<DataId, NodeId>) -> Self {
80 Self {
81 states: HashMap::new(),
82 cache: HashMap::new(),
83 source_map,
84 }
85 }
86
87 pub fn process_event(&mut self, event: &Event) -> bool {
91 match event {
92 Event::Input { id, data, .. } => {
93 self.states.insert(id.clone(), InputState::Healthy);
94 self.cache.insert(id.clone(), ArrowData(data.0.clone()));
95 true
96 }
97 Event::InputClosed { id } => {
98 self.states.insert(id.clone(), InputState::Closed);
99 true
101 }
102 Event::InputRecovered { id } => {
103 self.states.insert(id.clone(), InputState::Healthy);
104 true
105 }
106 Event::NodeRestarted { id: restarted } => {
107 for (input_id, source) in &self.source_map {
112 if source == restarted && self.states.get(input_id) == Some(&InputState::Closed)
113 {
114 self.states.insert(input_id.clone(), InputState::Healthy);
115 }
116 }
117 true
121 }
122 _ => false,
123 }
124 }
125
126 pub fn state(&self, id: &DataId) -> Option<InputState> {
128 self.states.get(id).copied()
129 }
130
131 pub fn is_closed(&self, id: &DataId) -> bool {
133 self.states.get(id) == Some(&InputState::Closed)
134 }
135
136 pub fn last_value(&self, id: &DataId) -> Option<&ArrowData> {
138 self.cache.get(id)
139 }
140
141 pub fn closed_inputs(&self) -> Vec<&DataId> {
143 self.states
144 .iter()
145 .filter(|(_, s)| **s == InputState::Closed)
146 .map(|(id, _)| id)
147 .collect()
148 }
149
150 pub fn any_closed(&self) -> bool {
152 self.states.values().any(|s| *s == InputState::Closed)
153 }
154}
155
156impl Default for InputTracker {
157 fn default() -> Self {
158 Self::new()
159 }
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165 use arrow::array::new_empty_array;
166 use arrow::datatypes::DataType;
167 use dora_message::metadata::Metadata;
168
169 fn empty_data() -> ArrowData {
170 ArrowData(new_empty_array(&DataType::Null))
171 }
172
173 fn test_metadata() -> Metadata {
174 Metadata::new(dora_core::uhlc::HLC::default().new_timestamp())
175 }
176
177 fn make_input(id: &str, data: ArrowData) -> Event {
178 Event::Input {
179 id: id.into(),
180 metadata: test_metadata(),
181 data,
182 }
183 }
184
185 #[test]
186 fn tracks_healthy_input() {
187 let mut t = InputTracker::new();
188 assert!(t.process_event(&make_input("a", empty_data())));
189 assert_eq!(t.state(&"a".into()), Some(InputState::Healthy));
190 assert!(!t.is_closed(&"a".into()));
191 assert!(t.last_value(&"a".into()).is_some());
192 }
193
194 #[test]
195 fn tracks_closed_preserves_cache() {
196 let mut t = InputTracker::new();
197 t.process_event(&make_input("a", empty_data()));
198 t.process_event(&Event::InputClosed { id: "a".into() });
199
200 assert_eq!(t.state(&"a".into()), Some(InputState::Closed));
201 assert!(t.is_closed(&"a".into()));
202 assert!(t.last_value(&"a".into()).is_some());
203 assert!(t.any_closed());
204 assert_eq!(t.closed_inputs().len(), 1);
205 }
206
207 #[test]
208 fn tracks_recovery() {
209 let mut t = InputTracker::new();
210 t.process_event(&make_input("a", empty_data()));
211 t.process_event(&Event::InputClosed { id: "a".into() });
212 t.process_event(&Event::InputRecovered { id: "a".into() });
213
214 assert_eq!(t.state(&"a".into()), Some(InputState::Healthy));
215 assert!(!t.any_closed());
216 }
217
218 #[test]
219 fn ignores_irrelevant_events() {
220 let mut t = InputTracker::new();
221 assert!(!t.process_event(&Event::Stop(super::super::event::StopCause::Manual)));
222 }
223
224 #[test]
227 fn node_restarted_without_source_map_is_acknowledged_but_noop() {
228 let mut t = InputTracker::new();
233 t.process_event(&make_input("a", empty_data()));
234 t.process_event(&Event::InputClosed { id: "a".into() });
235 assert!(t.is_closed(&"a".into()));
236
237 let relevant = t.process_event(&Event::NodeRestarted {
238 id: NodeId::from("upstream".to_string()),
239 });
240 assert!(relevant, "NodeRestarted should be reported as relevant");
241 assert!(t.is_closed(&"a".into()));
243 }
244
245 #[test]
246 fn node_restarted_with_source_map_recovers_matching_closed_inputs() {
247 let mut source_map = HashMap::new();
248 source_map.insert(
249 DataId::from("sensor".to_string()),
250 NodeId::from("camera".to_string()),
251 );
252 source_map.insert(
253 DataId::from("telemetry".to_string()),
254 NodeId::from("camera".to_string()),
255 );
256 source_map.insert(
257 DataId::from("config".to_string()),
258 NodeId::from("other".to_string()),
259 );
260 let mut t = InputTracker::with_source_map(source_map);
261
262 t.process_event(&Event::InputClosed {
264 id: "sensor".into(),
265 });
266 t.process_event(&Event::InputClosed {
267 id: "telemetry".into(),
268 });
269 t.process_event(&Event::InputClosed {
270 id: "config".into(),
271 });
272 assert_eq!(t.closed_inputs().len(), 3);
273
274 assert!(t.process_event(&Event::NodeRestarted {
276 id: NodeId::from("camera".to_string()),
277 }));
278
279 assert_eq!(t.state(&"sensor".into()), Some(InputState::Healthy));
280 assert_eq!(t.state(&"telemetry".into()), Some(InputState::Healthy));
281 assert_eq!(t.state(&"config".into()), Some(InputState::Closed));
282 }
283
284 #[test]
285 fn node_restarted_preserves_last_value_cache() {
286 let mut source_map = HashMap::new();
289 source_map.insert(
290 DataId::from("sensor".to_string()),
291 NodeId::from("camera".to_string()),
292 );
293 let mut t = InputTracker::with_source_map(source_map);
294
295 t.process_event(&make_input("sensor", empty_data()));
296 t.process_event(&Event::InputClosed {
297 id: "sensor".into(),
298 });
299 assert!(t.last_value(&"sensor".into()).is_some());
300
301 t.process_event(&Event::NodeRestarted {
302 id: NodeId::from("camera".to_string()),
303 });
304
305 assert_eq!(t.state(&"sensor".into()), Some(InputState::Healthy));
306 assert!(
307 t.last_value(&"sensor".into()).is_some(),
308 "cached value should survive restart so nodes can degrade gracefully"
309 );
310 }
311
312 #[test]
313 fn node_restarted_leaves_healthy_inputs_alone() {
314 let mut source_map = HashMap::new();
316 source_map.insert(
317 DataId::from("sensor".to_string()),
318 NodeId::from("camera".to_string()),
319 );
320 let mut t = InputTracker::with_source_map(source_map);
321
322 t.process_event(&make_input("sensor", empty_data()));
323 assert_eq!(t.state(&"sensor".into()), Some(InputState::Healthy));
324
325 t.process_event(&Event::NodeRestarted {
326 id: NodeId::from("camera".to_string()),
327 });
328 assert_eq!(t.state(&"sensor".into()), Some(InputState::Healthy));
329 }
330}