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/// ```
21/// use dora_node_api::{Event, InputState, InputTracker};
22///
23/// // Create a tracker up front (e.g. during node init).
24/// let mut tracker = InputTracker::new();
25///
26/// // Freshly created, no input is tracked yet.
27/// assert_eq!(tracker.state(&"camera".into()), None);
28/// assert!(!tracker.any_closed());
29/// assert_ne!(tracker.state(&"camera".into()), Some(InputState::Healthy));
30///
31/// // In your event loop, feed every event through the tracker, then fall
32/// // back to the last cached value for any input that has closed instead of
33/// // crashing. `Event` is `#[non_exhaustive]`, so match with a `_` arm.
34/// fn on_event(tracker: &mut InputTracker, event: &Event) {
35///     tracker.process_event(event);
36///     for id in tracker.closed_inputs() {
37///         if let Some(_stale) = tracker.last_value(id) {
38///             // degrade gracefully using the cached `ArrowData`
39///         }
40///     }
41/// }
42/// # let _ = on_event; // used from the real event loop
43/// ```
44pub struct InputTracker {
45    states: HashMap<DataId, InputState>,
46    cache: HashMap<DataId, ArrowData>,
47    /// Optional input → source node map. When provided, `NodeRestarted`
48    /// events transition any `Closed` inputs sourced from the restarted
49    /// node back to `Healthy`. Without it the tracker has no way to know
50    /// which inputs a given upstream node feeds, so `NodeRestarted`
51    /// leaves state unchanged (dora-rs/adora#148).
52    source_map: HashMap<DataId, NodeId>,
53}
54
55/// Health state of a tracked input.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum InputState {
58    /// The input is receiving data normally.
59    Healthy,
60    /// The input was closed (upstream exited or timed out).
61    Closed,
62}
63
64impl InputTracker {
65    /// Create a new, empty tracker.
66    ///
67    /// Without a source map, `NodeRestarted` events are acknowledged
68    /// (return `true` from `process_event`) but do not mutate state,
69    /// because the tracker cannot determine which inputs a given node
70    /// feeds. If you use fault-tolerance restart policies, prefer
71    /// [`InputTracker::with_source_map`] so closed inputs recover
72    /// automatically when the upstream comes back up.
73    pub fn new() -> Self {
74        Self {
75            states: HashMap::new(),
76            cache: HashMap::new(),
77            source_map: HashMap::new(),
78        }
79    }
80
81    /// Create a tracker that knows which input is fed by which upstream
82    /// node. With this map populated, `NodeRestarted { id }` events
83    /// transition any `Closed` inputs sourced from `id` back to `Healthy`.
84    ///
85    /// The map can be built from the dataflow's `input_config` at
86    /// initialization time — for each user input `(data_id, source_node)`
87    /// insert the pair here.
88    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    /// Update tracker state from an event. Returns `true` if the event
97    /// was relevant to input tracking (`Input`, `InputClosed`,
98    /// `InputRecovered`, or `NodeRestarted`).
99    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                // Cache preserved -- node can still read last_value
109                true
110            }
111            Event::InputRecovered { id } => {
112                self.states.insert(id.clone(), InputState::Healthy);
113                true
114            }
115            Event::NodeRestarted { id: restarted } => {
116                // Transition any closed inputs fed by the restarted node
117                // back to Healthy. Cached last_value is preserved so the
118                // node can still fall back to stale data between restart
119                // and first post-restart message.
120                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                // Always return true to signal relevance even when the
127                // tracker has no source map: callers inspecting the
128                // boolean can still detect that a lifecycle event arrived.
129                true
130            }
131            _ => false,
132        }
133    }
134
135    /// Get the current state of an input, if tracked.
136    pub fn state(&self, id: &DataId) -> Option<InputState> {
137        self.states.get(id).copied()
138    }
139
140    /// Check if an input is currently closed (timed out or upstream exited).
141    pub fn is_closed(&self, id: &DataId) -> bool {
142        self.states.get(id) == Some(&InputState::Closed)
143    }
144
145    /// Get the last received value for an input. Available even when closed.
146    pub fn last_value(&self, id: &DataId) -> Option<&ArrowData> {
147        self.cache.get(id)
148    }
149
150    /// Return all inputs that are currently closed.
151    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    /// Check if any tracked input is currently closed.
160    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    // ---- dora-rs/adora#148: NodeRestarted handling ----
234
235    #[test]
236    fn node_restarted_without_source_map_is_acknowledged_but_noop() {
237        // Without a source map the tracker has no way to know which inputs
238        // the restarted node feeds, so state is unchanged — but the event
239        // must still be reported as "relevant" (returns true) so callers
240        // that inspect the boolean don't treat the lifecycle signal as noise.
241        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        // State untouched: no source map, no safe way to transition.
251        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        // Close all three inputs.
272        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        // Only the `camera`-sourced inputs should recover.
284        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        // After a restart, cached stale data must still be readable so
296        // nodes can fall back to it between restart and first new message.
297        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        // A NodeRestarted event must not downgrade already-healthy inputs.
324        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}