Skip to main content

dora_node_api/event_stream/
input_tracker.rs

1use std::collections::HashMap;
2
3use dora_arrow_convert::ArrowData;
4use dora_core::config::{DataId, NodeId};
5
6use super::event::Event;
7
8/// Tracks the health state and last known value of each input.
9///
10/// Use this helper to implement graceful degradation when upstream nodes
11/// time out (via `input_timeout`). It caches the last received [`ArrowData`]
12/// per input so your node can fall back to stale data instead of crashing.
13///
14/// The cache is bounded by the number of distinct input IDs declared in the
15/// dataflow YAML. Since input IDs are fixed at dataflow compile time, the
16/// cache cannot grow unboundedly.
17///
18/// # Example
19///
20/// ```ignore
21/// let mut tracker = InputTracker::new();
22/// while let Some(event) = events.recv().await {
23///     tracker.process_event(&event);
24///     match event {
25///         Event::Input { id, data, .. } => { /* use fresh data */ }
26///         Event::InputClosed { id } => {
27///             if let Some(stale) = tracker.last_value(&id) {
28///                 /* degrade gracefully using cached value */
29///             }
30///         }
31///         _ => {}
32///     }
33/// }
34/// ```
35pub struct InputTracker {
36    states: HashMap<DataId, InputState>,
37    cache: HashMap<DataId, ArrowData>,
38    /// Optional input → source node map. When provided, `NodeRestarted`
39    /// events transition any `Closed` inputs sourced from the restarted
40    /// node back to `Healthy`. Without it the tracker has no way to know
41    /// which inputs a given upstream node feeds, so `NodeRestarted`
42    /// leaves state unchanged (dora-rs/adora#148).
43    source_map: HashMap<DataId, NodeId>,
44}
45
46/// Health state of a tracked input.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum InputState {
49    /// The input is receiving data normally.
50    Healthy,
51    /// The input was closed (upstream exited or timed out).
52    Closed,
53}
54
55impl InputTracker {
56    /// Create a new, empty tracker.
57    ///
58    /// Without a source map, `NodeRestarted` events are acknowledged
59    /// (return `true` from `process_event`) but do not mutate state,
60    /// because the tracker cannot determine which inputs a given node
61    /// feeds. If you use fault-tolerance restart policies, prefer
62    /// [`InputTracker::with_source_map`] so closed inputs recover
63    /// automatically when the upstream comes back up.
64    pub fn new() -> Self {
65        Self {
66            states: HashMap::new(),
67            cache: HashMap::new(),
68            source_map: HashMap::new(),
69        }
70    }
71
72    /// Create a tracker that knows which input is fed by which upstream
73    /// node. With this map populated, `NodeRestarted { id }` events
74    /// transition any `Closed` inputs sourced from `id` back to `Healthy`.
75    ///
76    /// The map can be built from the dataflow's `input_config` at
77    /// initialization time — for each user input `(data_id, source_node)`
78    /// insert the pair here.
79    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    /// Update tracker state from an event. Returns `true` if the event
88    /// was relevant to input tracking (`Input`, `InputClosed`,
89    /// `InputRecovered`, or `NodeRestarted`).
90    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                // Cache preserved -- node can still read last_value
100                true
101            }
102            Event::InputRecovered { id } => {
103                self.states.insert(id.clone(), InputState::Healthy);
104                true
105            }
106            Event::NodeRestarted { id: restarted } => {
107                // Transition any closed inputs fed by the restarted node
108                // back to Healthy. Cached last_value is preserved so the
109                // node can still fall back to stale data between restart
110                // and first post-restart message.
111                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                // Always return true to signal relevance even when the
118                // tracker has no source map: callers inspecting the
119                // boolean can still detect that a lifecycle event arrived.
120                true
121            }
122            _ => false,
123        }
124    }
125
126    /// Get the current state of an input, if tracked.
127    pub fn state(&self, id: &DataId) -> Option<InputState> {
128        self.states.get(id).copied()
129    }
130
131    /// Check if an input is currently closed (timed out or upstream exited).
132    pub fn is_closed(&self, id: &DataId) -> bool {
133        self.states.get(id) == Some(&InputState::Closed)
134    }
135
136    /// Get the last received value for an input. Available even when closed.
137    pub fn last_value(&self, id: &DataId) -> Option<&ArrowData> {
138        self.cache.get(id)
139    }
140
141    /// Return all inputs that are currently closed.
142    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    /// Check if any tracked input is currently closed.
151    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    // ---- dora-rs/adora#148: NodeRestarted handling ----
225
226    #[test]
227    fn node_restarted_without_source_map_is_acknowledged_but_noop() {
228        // Without a source map the tracker has no way to know which inputs
229        // the restarted node feeds, so state is unchanged — but the event
230        // must still be reported as "relevant" (returns true) so callers
231        // that inspect the boolean don't treat the lifecycle signal as noise.
232        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        // State untouched: no source map, no safe way to transition.
242        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        // Close all three inputs.
263        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        // Only the `camera`-sourced inputs should recover.
275        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        // After a restart, cached stale data must still be readable so
287        // nodes can fall back to it between restart and first new message.
288        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        // A NodeRestarted event must not downgrade already-healthy inputs.
315        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}