Skip to main content

dora_node_api/event_stream/
scheduler.rs

1use std::{
2    collections::{HashMap, VecDeque},
3    sync::LazyLock,
4};
5
6use dora_message::{
7    config::{DEFAULT_QUEUE_SIZE, QueuePolicy},
8    daemon_to_node::NodeEvent,
9    id::DataId,
10    metadata::{
11        GOAL_ID, GOAL_STATUS, MetadataParameters, REQUEST_ID, carries_pattern_correlation,
12        get_string_param,
13    },
14};
15
16use super::thread::EventItem;
17
18/// The metadata parameters carried by an input-bearing event, or `None` for
19/// events with no metadata (Stop, reload, input-closed, ...).
20///
21/// This is the single place that decides which `EventItem` variants carry
22/// parameters, so the eviction guard (`is_correlated`) and its diagnostics
23/// (`log_correlation_drop`) can never disagree on that classification.
24fn event_parameters(event: &EventItem) -> Option<&MetadataParameters> {
25    match event {
26        EventItem::NodeEvent {
27            event: NodeEvent::Input { metadata, .. },
28            ..
29        } => Some(&metadata.parameters),
30        EventItem::ZenohInput { metadata, .. } => Some(&metadata.parameters),
31        _ => None,
32    }
33}
34
35/// Returns `true` if the event carries request/response or action correlation
36/// metadata (`request_id`, `goal_id`, or `goal_status`).
37///
38/// These keys bind a message to a specific service request or action goal.
39/// Silently dropping such a message breaks the correlation contract — the
40/// client waits forever for a response or result that never arrives
41/// (dora-rs/adora#145).
42fn is_correlated(event: &EventItem) -> bool {
43    // Delegate to the canonical definition in `dora_message` so the scheduler's
44    // eviction guard can never disagree with the send-side / receive-side /
45    // daemon-debug layers about which keys mark a message as pattern-correlated
46    // (see `carries_pattern_correlation`). Duplicating the key list here risked
47    // silently dropping service/action messages if a new correlation key were
48    // added to only one copy.
49    event_parameters(event).is_some_and(carries_pattern_correlation)
50}
51
52/// Returns `true` if the event is the daemon's [`Stop`][NodeEvent::Stop]
53/// shutdown signal.
54///
55/// `Stop` must never be silently evicted from a full queue: dropping it makes
56/// the node miss shutdown entirely and keep running until the daemon's
57/// force-kill grace deadline. Since at most one `Stop` is ever in flight,
58/// making it eviction-immune cannot let the queue grow unbounded.
59fn is_stop(event: &EventItem) -> bool {
60    matches!(
61        event,
62        EventItem::NodeEvent {
63            event: NodeEvent::Stop,
64            ..
65        }
66    )
67}
68
69/// Outcome of `select_eviction`.
70enum Eviction {
71    /// Remove event at this index from the queue and push the incoming event.
72    RemoveAt(usize),
73    /// The queue holds only events that must be preserved (correlated and/or
74    /// the `Stop`) and the incoming event is an ordinary one — drop the
75    /// incoming event instead of breaking a correlation or losing `Stop`.
76    DropIncoming,
77    /// The queue is entirely correlated (plus possibly the `Stop`) and the
78    /// incoming event must also be preserved — drop the oldest *correlated*
79    /// event at this index with a loud error log, never the `Stop`.
80    DropCorrelatedLoud(usize),
81}
82
83/// Choose which event to drop when the queue is at capacity.
84///
85/// Prefers sacrificing ordinary (non-correlated, non-`Stop`) events so that
86/// service responses, action results, and the shutdown signal survive. See
87/// `is_correlated` for the metadata keys that mark an event as part of a
88/// pattern, and `is_stop` for why `Stop` is eviction-immune.
89fn select_eviction(queue: &VecDeque<EventItem>, incoming: &EventItem) -> Eviction {
90    // 1. Sacrifice the oldest ordinary event, never a correlation or `Stop`.
91    if let Some(idx) = queue.iter().position(|e| !is_correlated(e) && !is_stop(e)) {
92        return Eviction::RemoveAt(idx);
93    }
94    // 2. Nothing ordinary left to sacrifice. If the incoming event is itself
95    //    ordinary, drop it rather than a correlation or the `Stop`.
96    if !is_correlated(incoming) && !is_stop(incoming) {
97        return Eviction::DropIncoming;
98    }
99    // 3. Both the queue and the incoming event must be preserved. Drop the
100    //    oldest *correlated* event (loudly), keeping the `Stop` intact. If the
101    //    queue somehow contains no correlated event (only a `Stop`, which is
102    //    at most one), fall back to dropping the incoming event.
103    match queue.iter().position(|e| !is_stop(e)) {
104        Some(idx) => Eviction::DropCorrelatedLoud(idx),
105        None => Eviction::DropIncoming,
106    }
107}
108
109/// Emit a loud error when a correlated event has to be dropped because
110/// everything in the queue is also correlated. Identifies the correlation
111/// keys so operators can trace the affected request/goal.
112fn log_correlation_drop(event_id: &DataId, dropped: &EventItem) {
113    let Some(params) = event_parameters(dropped) else {
114        return;
115    };
116    let request_id = get_string_param(params, REQUEST_ID);
117    let goal_id = get_string_param(params, GOAL_ID);
118    let goal_status = get_string_param(params, GOAL_STATUS);
119    tracing::error!(
120        input = %event_id,
121        ?request_id,
122        ?goal_id,
123        ?goal_status,
124        "queue full of correlated messages; dropping oldest correlation. \
125         This breaks the service/action request-response contract. \
126         Consider increasing queue_size or switching this input to \
127         `queue_policy: backpressure`."
128    );
129}
130pub(crate) const NON_INPUT_EVENT: &str = "dora.non_input_event";
131
132/// Shared [`DataId`] for [`NON_INPUT_EVENT`], so the hot `add_event`/`next`
133/// paths don't have to allocate a fresh `String` on every call.
134static NON_INPUT_EVENT_ID: LazyLock<DataId> =
135    LazyLock::new(|| DataId::from(NON_INPUT_EVENT.to_string()));
136
137/// This scheduler will make sure that there is fairness between inputs.
138///
139/// The scheduler reorders events in the following way:
140///
141/// - **Non-input events are prioritized**
142///   
143///   If the node received any events that are not input events, they are returned first. The
144///   intention of this reordering is that the nodes can react quickly to dataflow-related events
145///   even when their input queues are very full.
146///   
147///   This reordering has some side effects that might be unexpected:
148///   - An [`InputClosed`][super::Event::InputClosed] event might be yielded before the last
149///     input events of that ID.
150///     
151///     Usually, an `InputClosed` event indicates that there won't be any subsequent inputs
152///     of a certain ID. This invariant does not hold anymore for a scheduled event stream.
153///   - The [`Stop`][super::Event::Stop] event might not be the last event of the stream anymore.
154///     
155///     Usually, the `Stop` event is the last event that is sent to a node before the event stream
156///     is closed. Because of the reordering, the stream might return more events after a `Stop`
157///     event.
158/// - **Input events are grouped by ID** and yielded in a **least-recently used order (by ID)**.
159///
160///   The scheduler keeps a separate queue for each input ID, where the incoming input events are
161///   placed in their chronological order. When yielding the next event, the scheduler iterates over
162///   these queues in least-recently used order. This means that the queue corresponding to the
163///   last yielded event will be checked last. The scheduler will return the oldest event from the
164///   first non-empty queue.
165///
166///   The side effect of this change is that inputs events of different IDs are no longer in their
167///   chronological order. This might lead to unexpected results for input events that are caused by
168///   each other.
169///
170/// ## Example 1
171/// Consider the case that one input has a very high frequency and another one with a very slow
172/// frequency. The event stream will always alternate between the two inputs when each input is
173/// available.
174/// Without the scheduling, the high-frequency input would be returned much more often.
175///
176/// ## Example 2
177/// Again, let's consider the case that one input has a very high frequency and the other has a
178/// very slow frequency. This time, we define a small maximum queue sizes for the low-frequency
179/// input, but a large queue size for the high-frequency one.
180/// Using the scheduler, the event stream will always alternate between high and low-frequency
181/// inputs as long as inputs of both types are available.
182///
183/// Without scheduling, the low-frequency input might never be yielded before
184/// it's dropped because there is almost always an older high-frequency input available that is
185/// yielded first. Once the low-frequency input would be the next one chronologically, it might
186/// have been dropped already because the node received newer low-frequency inputs in the
187/// meantime (the queue length is small). At this point, the next-oldest input is a high-frequency
188/// input again.
189///
190/// ## Example 3
191/// Consider a high-frequency camera input and a low-frequency bounding box input, which is based
192/// on the latest camera image. The dataflow YAML file specifies a large queue size for the camera
193/// input and a small queue size for the bounding box input.
194///
195/// With scheduling, the number of
196/// buffered camera inputs might grow over time. As a result the camera inputs yielded from the
197/// stream (in oldest-first order) are not synchronized with the bounding box inputs anymore. So
198/// the node receives an up-to-date bounding box, but a considerably outdated image.
199///
200/// Without scheduling, the events are returned in chronological order. This time, the bounding
201/// box might be slightly outdated if the camera sent new images before the bounding box was
202/// ready. However, the time difference between the two input types is independent of the
203/// queue size this time.
204///
205/// (If a perfect matching bounding box is required, we recommend to forward the input image as
206/// part of the bounding box output. This way, the receiving node only needs to subscribe to one
207/// input so no mismatches can happen.)
208#[derive(Debug)]
209pub struct Scheduler {
210    /// Tracks the last-used event ID
211    last_used: VecDeque<DataId>,
212    /// Tracks events per ID
213    event_queues: HashMap<DataId, (usize, VecDeque<EventItem>)>,
214    /// Queue policies per input ID
215    queue_policies: HashMap<DataId, QueuePolicy>,
216    /// Drop counters per input ID
217    dropped: HashMap<DataId, u64>,
218}
219
220impl Scheduler {
221    pub(crate) fn with_policies(
222        event_queues: HashMap<DataId, (usize, VecDeque<EventItem>)>,
223        queue_policies: HashMap<DataId, QueuePolicy>,
224    ) -> Self {
225        let topic = VecDeque::from_iter(
226            event_queues
227                .keys()
228                .filter(|t| **t != *NON_INPUT_EVENT_ID)
229                .cloned(),
230        );
231        Self {
232            last_used: topic,
233            event_queues,
234            queue_policies,
235            dropped: HashMap::new(),
236        }
237    }
238
239    /// Returns and resets the accumulated drop counts per input ID.
240    pub fn drain_drop_counts(&mut self) -> HashMap<DataId, u64> {
241        std::mem::take(&mut self.dropped)
242    }
243
244    pub(crate) fn add_event(&mut self, event: EventItem) {
245        let (event_id, should_flush) = match &event {
246            EventItem::NodeEvent {
247                event: NodeEvent::Input { id, metadata, .. },
248                ..
249            } => {
250                let flush = dora_message::metadata::get_bool_param(
251                    &metadata.parameters,
252                    dora_message::metadata::FLUSH,
253                ) == Some(true);
254                (id, flush)
255            }
256            EventItem::ZenohInput { id, metadata, .. } => {
257                let flush = dora_message::metadata::get_bool_param(
258                    &metadata.parameters,
259                    dora_message::metadata::FLUSH,
260                ) == Some(true);
261                (id, flush)
262            }
263            _ => (&*NON_INPUT_EVENT_ID, false),
264        };
265
266        // Flush older queued messages when flush=true is present.
267        //
268        // Streaming pattern's `flush: true` means "discard stale stream chunks".
269        // It must NOT wipe service responses or action results that happen to
270        // share the same input, because those carry `request_id` / `goal_id` /
271        // `goal_status` correlations whose senders are waiting for them
272        // (dora-rs/adora#146). Use the same correlation predicate that the
273        // drop_oldest path uses and retain correlated events across the flush.
274        // Also retain the `Stop` shutdown signal (eviction-immune everywhere,
275        // see `is_stop`): flush normally targets a per-input queue and `Stop`
276        // lives under `NON_INPUT_EVENT_ID`, but the two collide if an input is
277        // literally named `dora.non_input_event`, which `validate_data_id`
278        // permits — so guard the flush path too rather than rely on that.
279        if should_flush && let Some((_size, queue)) = self.event_queues.get_mut(event_id) {
280            let before = queue.len();
281            queue.retain(|e| is_correlated(e) || is_stop(e));
282            let drained = before - queue.len();
283            if drained > 0 {
284                tracing::debug!(
285                    "Flushed {drained} queued event(s) for input `{event_id}` (flush signal)"
286                );
287            }
288            if !queue.is_empty() {
289                tracing::debug!(
290                    input = %event_id,
291                    preserved = queue.len(),
292                    "flush signal retained correlated (request_id/goal_id) events"
293                );
294            }
295        }
296
297        // Enforce queue size limit.
298        //
299        // The queue is normally preconfigured for every input at construction
300        // (see `with_policies`), so look it up by reference first to avoid
301        // cloning the `DataId` key on every event. Only the rare unconfigured
302        // input path needs to allocate an owned key for insertion.
303        if !self.event_queues.contains_key(event_id) {
304            tracing::warn!(
305                "no queue config for input `{event_id}`, using default size {DEFAULT_QUEUE_SIZE}"
306            );
307            self.last_used.push_back(event_id.clone());
308            self.event_queues
309                .insert(event_id.clone(), (DEFAULT_QUEUE_SIZE, Default::default()));
310        }
311        let Some((size, queue)) = self.event_queues.get_mut(event_id) else {
312            // Unreachable: the entry was just inserted above when missing.
313            return;
314        };
315
316        let policy = self
317            .queue_policies
318            .get(event_id)
319            .copied()
320            .unwrap_or_default();
321
322        let cap = policy.effective_cap(*size);
323        if queue.len() >= cap {
324            if policy == QueuePolicy::Backpressure {
325                tracing::error!(
326                    "Backpressure input `{event_id}` hit hard cap ({cap}), \
327                     dropping oldest to prevent OOM"
328                );
329            } else {
330                tracing::warn!("Discarding event for input `{event_id}` due to queue size limit");
331            }
332            *self.dropped.entry(event_id.clone()).or_insert(0) += 1;
333            match select_eviction(queue, &event) {
334                Eviction::RemoveAt(idx) => {
335                    queue.remove(idx);
336                }
337                Eviction::DropIncoming => {
338                    // Queue is entirely correlated; preserve correlations
339                    // by dropping the incoming (non-correlated) event.
340                    return;
341                }
342                Eviction::DropCorrelatedLoud(idx) => {
343                    if let Some(dropped) = queue.remove(idx) {
344                        log_correlation_drop(event_id, &dropped);
345                    }
346                }
347            }
348        }
349        queue.push_back(event);
350    }
351
352    pub(crate) fn next(&mut self) -> Option<EventItem> {
353        // Retrieve message from the non input event first that have priority over input message.
354        if let Some((_size, queue)) = self.event_queues.get_mut(&*NON_INPUT_EVENT_ID)
355            && let Some(event) = queue.pop_front()
356        {
357            return Some(event);
358        }
359
360        // Yield from the first non-empty input queue in least-recently-used
361        // order: `last_used` is a VecDeque of input IDs, and the ID we serve
362        // from is rotated to the back below so the others get a turn next.
363        for index in 0..self.last_used.len() {
364            let id = &self.last_used[index];
365            if let Some((_size, queue)) = self.event_queues.get_mut(id)
366                && let Some(event) = queue.pop_front()
367            {
368                // Put last used at last
369                if let Some(id) = self.last_used.remove(index) {
370                    self.last_used.push_back(id);
371                }
372                return Some(event);
373            }
374        }
375
376        None
377    }
378
379    pub(crate) fn is_empty(&self) -> bool {
380        self.event_queues
381            .iter()
382            .all(|(_id, (_size, queue))| queue.is_empty())
383    }
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389    use crate::uhlc;
390    use dora_message::{
391        daemon_to_node::NodeEvent,
392        metadata::{FLUSH, Metadata, MetadataParameters, Parameter},
393    };
394
395    fn make_input(id: &str, params: MetadataParameters) -> EventItem {
396        let ts = uhlc::HLC::default().new_timestamp();
397        let metadata = Metadata::from_parameters(ts, params);
398        EventItem::NodeEvent {
399            event: NodeEvent::Input {
400                id: DataId::from(id.to_string()),
401                metadata: std::sync::Arc::new(metadata),
402                data: None,
403            },
404        }
405    }
406
407    fn make_stop() -> EventItem {
408        EventItem::NodeEvent {
409            event: NodeEvent::Stop,
410        }
411    }
412
413    fn make_input_closed(id: &str) -> EventItem {
414        EventItem::NodeEvent {
415            event: NodeEvent::InputClosed {
416                id: DataId::from(id.to_string()),
417            },
418        }
419    }
420
421    fn make_scheduler(audio_capacity: usize) -> (Scheduler, DataId) {
422        let id = DataId::from("audio".to_string());
423        let mut queues = HashMap::new();
424        queues.insert(id.clone(), (audio_capacity, VecDeque::new()));
425        queues.insert(
426            DataId::from(NON_INPUT_EVENT.to_string()),
427            (10, VecDeque::new()),
428        );
429        (Scheduler::with_policies(queues, HashMap::new()), id)
430    }
431
432    #[test]
433    fn flush_clears_older_queued_events() {
434        let (mut sched, id) = make_scheduler(10);
435
436        sched.add_event(make_input("audio", MetadataParameters::new()));
437        sched.add_event(make_input("audio", MetadataParameters::new()));
438        sched.add_event(make_input("audio", MetadataParameters::new()));
439        assert_eq!(sched.event_queues[&id].1.len(), 3);
440
441        // Flush should clear the 3 older events, then insert itself
442        let mut flush_params = MetadataParameters::new();
443        flush_params.insert(FLUSH.into(), Parameter::Bool(true));
444        sched.add_event(make_input("audio", flush_params));
445
446        assert_eq!(sched.event_queues[&id].1.len(), 1);
447    }
448
449    #[test]
450    fn non_flush_does_not_clear_queue() {
451        let (mut sched, id) = make_scheduler(10);
452
453        sched.add_event(make_input("audio", MetadataParameters::new()));
454        sched.add_event(make_input("audio", MetadataParameters::new()));
455        sched.add_event(make_input("audio", MetadataParameters::new()));
456        assert_eq!(sched.event_queues[&id].1.len(), 3);
457    }
458
459    #[test]
460    fn flush_false_does_not_clear_queue() {
461        let (mut sched, id) = make_scheduler(10);
462
463        sched.add_event(make_input("audio", MetadataParameters::new()));
464        sched.add_event(make_input("audio", MetadataParameters::new()));
465
466        let mut params = MetadataParameters::new();
467        params.insert(FLUSH.into(), Parameter::Bool(false));
468        sched.add_event(make_input("audio", params));
469
470        assert_eq!(sched.event_queues[&id].1.len(), 3);
471    }
472
473    #[test]
474    fn flush_with_queue_size_one_retains_flush_message() {
475        let (mut sched, id) = make_scheduler(1);
476
477        sched.add_event(make_input("audio", MetadataParameters::new()));
478        assert_eq!(sched.event_queues[&id].1.len(), 1);
479
480        // Flush clears the queue, then the flush message itself is inserted
481        let mut flush_params = MetadataParameters::new();
482        flush_params.insert(FLUSH.into(), Parameter::Bool(true));
483        sched.add_event(make_input("audio", flush_params));
484
485        // The flush message should survive (queue was cleared to 0, then inserted)
486        assert_eq!(sched.event_queues[&id].1.len(), 1);
487    }
488
489    #[test]
490    fn drop_oldest_tracks_drop_count() {
491        let (mut sched, id) = make_scheduler(2);
492
493        // Fill to capacity
494        sched.add_event(make_input("audio", MetadataParameters::new()));
495        sched.add_event(make_input("audio", MetadataParameters::new()));
496        assert_eq!(sched.event_queues[&id].1.len(), 2);
497
498        // Overflow by 3 more
499        sched.add_event(make_input("audio", MetadataParameters::new()));
500        sched.add_event(make_input("audio", MetadataParameters::new()));
501        sched.add_event(make_input("audio", MetadataParameters::new()));
502
503        // Queue stays at capacity
504        assert_eq!(sched.event_queues[&id].1.len(), 2);
505
506        // 3 drops counted
507        let counts = sched.drain_drop_counts();
508        assert_eq!(counts.get(&id), Some(&3));
509
510        // After drain, counts reset
511        let counts = sched.drain_drop_counts();
512        assert!(counts.is_empty());
513    }
514
515    // ---- dora-rs/adora#146: flush: true must not wipe correlated messages ----
516
517    #[test]
518    fn flush_retains_correlated_events() {
519        // Queue holds a service response (request_id) and two stream chunks.
520        // A flush signal should drop the stream chunks but keep the response.
521        let (mut sched, id) = make_scheduler(10);
522
523        sched.add_event(make_input("audio", with_request_id("req-1")));
524        sched.add_event(make_input("audio", MetadataParameters::new()));
525        sched.add_event(make_input("audio", MetadataParameters::new()));
526        assert_eq!(sched.event_queues[&id].1.len(), 3);
527
528        let mut flush_params = MetadataParameters::new();
529        flush_params.insert(FLUSH.into(), Parameter::Bool(true));
530        sched.add_event(make_input("audio", flush_params));
531
532        let queue = &sched.event_queues[&id].1;
533        // Expect: [req-1, flush_message]
534        assert_eq!(queue.len(), 2);
535        assert!(
536            queue
537                .iter()
538                .any(|e| request_id_of(e).as_deref() == Some("req-1")),
539            "service response with request_id was wiped by flush"
540        );
541    }
542
543    #[test]
544    fn flush_retains_goal_id_events() {
545        // Same preservation via goal_id.
546        let (mut sched, id) = make_scheduler(10);
547
548        let mut goal_params = MetadataParameters::new();
549        goal_params.insert(GOAL_ID.into(), Parameter::String("goal-7".to_string()));
550        sched.add_event(make_input("audio", goal_params));
551        sched.add_event(make_input("audio", MetadataParameters::new()));
552
553        let mut flush_params = MetadataParameters::new();
554        flush_params.insert(FLUSH.into(), Parameter::Bool(true));
555        sched.add_event(make_input("audio", flush_params));
556
557        let queue = &sched.event_queues[&id].1;
558        // Expect: [goal-7, flush_message]
559        assert_eq!(queue.len(), 2);
560        let has_goal = queue.iter().any(|e| {
561            let EventItem::NodeEvent {
562                event: NodeEvent::Input { metadata, .. },
563                ..
564            } = e
565            else {
566                return false;
567            };
568            get_string_param(&metadata.parameters, GOAL_ID) == Some("goal-7")
569        });
570        assert!(has_goal, "action result with goal_id was wiped by flush");
571    }
572
573    #[test]
574    fn flush_with_all_correlated_queue_keeps_everything() {
575        // All queued events are correlations. Flush should preserve them all,
576        // then admit the flush message itself.
577        let (mut sched, id) = make_scheduler(10);
578
579        sched.add_event(make_input("audio", with_request_id("req-1")));
580        sched.add_event(make_input("audio", with_request_id("req-2")));
581        sched.add_event(make_input("audio", with_request_id("req-3")));
582
583        let mut flush_params = MetadataParameters::new();
584        flush_params.insert(FLUSH.into(), Parameter::Bool(true));
585        sched.add_event(make_input("audio", flush_params));
586
587        // Expect: [req-1, req-2, req-3, flush_message]
588        assert_eq!(sched.event_queues[&id].1.len(), 4);
589    }
590
591    // ---- dora-rs/adora#145: drop_oldest must not silently drop correlated messages ----
592
593    /// Helper: extract request_id from an event's metadata, if any.
594    fn request_id_of(event: &EventItem) -> Option<String> {
595        let EventItem::NodeEvent {
596            event: NodeEvent::Input { metadata, .. },
597            ..
598        } = event
599        else {
600            return None;
601        };
602        get_string_param(&metadata.parameters, REQUEST_ID).map(|s| s.to_string())
603    }
604
605    fn with_request_id(id: &str) -> MetadataParameters {
606        let mut params = MetadataParameters::new();
607        params.insert(REQUEST_ID.into(), Parameter::String(id.to_string()));
608        params
609    }
610
611    #[test]
612    fn drop_oldest_preserves_correlated_when_non_correlated_present() {
613        // Queue has [correlated(req-1), non-correlated, non-correlated]
614        // Adding one more should drop a non-correlated event, not req-1.
615        let (mut sched, id) = make_scheduler(3);
616
617        sched.add_event(make_input("audio", with_request_id("req-1")));
618        sched.add_event(make_input("audio", MetadataParameters::new()));
619        sched.add_event(make_input("audio", MetadataParameters::new()));
620        sched.add_event(make_input("audio", MetadataParameters::new()));
621
622        let queue = &sched.event_queues[&id].1;
623        assert_eq!(queue.len(), 3);
624        // req-1 must still be somewhere in the queue
625        assert!(
626            queue
627                .iter()
628                .any(|e| request_id_of(e).as_deref() == Some("req-1")),
629            "correlated message was dropped even though non-correlated events were available"
630        );
631    }
632
633    #[test]
634    fn drop_oldest_drops_middle_non_correlated_to_save_front_correlated() {
635        // Queue: [req-1 (correlated), B, req-2 (correlated)]
636        // Adding C should drop B (the only non-correlated), not req-1.
637        let (mut sched, id) = make_scheduler(3);
638
639        sched.add_event(make_input("audio", with_request_id("req-1")));
640        sched.add_event(make_input("audio", MetadataParameters::new()));
641        sched.add_event(make_input("audio", with_request_id("req-2")));
642        sched.add_event(make_input("audio", MetadataParameters::new()));
643
644        let queue = &sched.event_queues[&id].1;
645        assert_eq!(queue.len(), 3);
646        assert!(
647            queue
648                .iter()
649                .any(|e| request_id_of(e).as_deref() == Some("req-1"))
650        );
651        assert!(
652            queue
653                .iter()
654                .any(|e| request_id_of(e).as_deref() == Some("req-2"))
655        );
656    }
657
658    #[test]
659    fn drop_oldest_drops_incoming_if_queue_is_fully_correlated_and_incoming_is_not() {
660        // Queue: [req-1, req-2] (both correlated). Incoming is non-correlated.
661        // The correlations must survive; incoming gets dropped instead.
662        let (mut sched, id) = make_scheduler(2);
663
664        sched.add_event(make_input("audio", with_request_id("req-1")));
665        sched.add_event(make_input("audio", with_request_id("req-2")));
666        sched.add_event(make_input("audio", MetadataParameters::new()));
667
668        let queue = &sched.event_queues[&id].1;
669        assert_eq!(queue.len(), 2);
670        let ids: Vec<_> = queue.iter().filter_map(request_id_of).collect();
671        assert_eq!(ids, vec!["req-1".to_string(), "req-2".to_string()]);
672
673        // Drop counter still increments — we rejected a message.
674        let counts = sched.drain_drop_counts();
675        assert_eq!(counts.get(&id), Some(&1));
676    }
677
678    #[test]
679    fn drop_oldest_drops_front_loudly_when_both_queue_and_incoming_are_correlated() {
680        // Queue: [req-1, req-2] (both correlated). Incoming is req-3.
681        // Unavoidable drop — the oldest correlation (req-1) is evicted.
682        let (mut sched, id) = make_scheduler(2);
683
684        sched.add_event(make_input("audio", with_request_id("req-1")));
685        sched.add_event(make_input("audio", with_request_id("req-2")));
686        sched.add_event(make_input("audio", with_request_id("req-3")));
687
688        let queue = &sched.event_queues[&id].1;
689        assert_eq!(queue.len(), 2);
690        let ids: Vec<_> = queue.iter().filter_map(request_id_of).collect();
691        assert_eq!(ids, vec!["req-2".to_string(), "req-3".to_string()]);
692    }
693
694    #[test]
695    fn drop_oldest_goal_id_is_also_preserved() {
696        // Same preservation as request_id, but via goal_id.
697        let (mut sched, id) = make_scheduler(2);
698
699        let mut goal_params = MetadataParameters::new();
700        goal_params.insert(GOAL_ID.into(), Parameter::String("goal-42".to_string()));
701
702        sched.add_event(make_input("audio", goal_params));
703        sched.add_event(make_input("audio", MetadataParameters::new()));
704        sched.add_event(make_input("audio", MetadataParameters::new()));
705
706        let queue = &sched.event_queues[&id].1;
707        assert_eq!(queue.len(), 2);
708        let has_goal = queue.iter().any(|e| {
709            let EventItem::NodeEvent {
710                event: NodeEvent::Input { metadata, .. },
711                ..
712            } = e
713            else {
714                return false;
715            };
716            get_string_param(&metadata.parameters, GOAL_ID) == Some("goal-42")
717        });
718        assert!(
719            has_goal,
720            "goal-42 was dropped despite having non-correlated events to drop"
721        );
722    }
723
724    // The non-input queue holds every lifecycle event (`Stop`, `InputClosed`,
725    // `Error`, ...) under one cap. Overflow must never evict the `Stop`
726    // shutdown signal: dropping it makes the node miss shutdown and run until
727    // the daemon force-kills it. A `Stop` buffered while the node is busy must
728    // survive a flood of other non-input events that overflows the queue many
729    // times over.
730    #[test]
731    fn stop_survives_non_input_queue_overflow() {
732        // `make_scheduler` gives the non-input queue a cap of 10.
733        let (mut sched, _id) = make_scheduler(10);
734
735        sched.add_event(make_stop());
736        for i in 0..100 {
737            sched.add_event(make_input_closed(&format!("in-{i}")));
738        }
739
740        let non_input = &sched.event_queues[&*NON_INPUT_EVENT_ID].1;
741        assert_eq!(non_input.len(), 10, "non-input queue must stay bounded");
742        assert!(
743            non_input.iter().any(is_stop),
744            "the Stop event must survive non-input queue overflow"
745        );
746    }
747
748    // The incoming event is a `Stop` and the queue is already full of ordinary
749    // non-input events: the `Stop` must be admitted (evicting an ordinary
750    // event), not dropped as the overflow victim.
751    #[test]
752    fn incoming_stop_is_admitted_into_a_full_non_input_queue() {
753        let (mut sched, _id) = make_scheduler(10);
754
755        for i in 0..10 {
756            sched.add_event(make_input_closed(&format!("in-{i}")));
757        }
758        sched.add_event(make_stop());
759
760        let non_input = &sched.event_queues[&*NON_INPUT_EVENT_ID].1;
761        assert_eq!(non_input.len(), 10, "non-input queue must stay bounded");
762        assert!(
763            non_input.iter().any(is_stop),
764            "an incoming Stop must be admitted into a full non-input queue"
765        );
766    }
767
768    // The flush path (`retain`) is a second eviction site. It normally targets
769    // a per-input queue, but an input literally named `dora.non_input_event`
770    // (which `validate_data_id` permits) collides with the queue where `Stop`
771    // lives. Flush must still preserve the `Stop` shutdown signal there.
772    #[test]
773    fn flush_retains_stop_when_targeting_the_non_input_queue() {
774        let (mut sched, _id) = make_scheduler(10);
775
776        sched.add_event(make_stop());
777        sched.add_event(make_input_closed("x"));
778
779        let mut flush_params = MetadataParameters::new();
780        flush_params.insert(FLUSH.into(), Parameter::Bool(true));
781        sched.add_event(make_input(NON_INPUT_EVENT, flush_params));
782
783        let non_input = &sched.event_queues[&*NON_INPUT_EVENT_ID].1;
784        assert!(
785            non_input.iter().any(is_stop),
786            "flush must not evict the Stop shutdown signal"
787        );
788    }
789
790    #[test]
791    fn backpressure_policy_prevents_drops() {
792        let id = DataId::from("commands".to_string());
793        let mut queues = HashMap::new();
794        queues.insert(id.clone(), (2, VecDeque::new()));
795        queues.insert(
796            DataId::from(NON_INPUT_EVENT.to_string()),
797            (10, VecDeque::new()),
798        );
799        let policies = HashMap::from([(id.clone(), QueuePolicy::Backpressure)]);
800        let mut sched = Scheduler::with_policies(queues, policies);
801
802        // Fill past capacity — backpressure should let queue grow
803        sched.add_event(make_input("commands", MetadataParameters::new()));
804        sched.add_event(make_input("commands", MetadataParameters::new()));
805        sched.add_event(make_input("commands", MetadataParameters::new()));
806        sched.add_event(make_input("commands", MetadataParameters::new()));
807
808        // Queue grew beyond configured size (no drops)
809        assert_eq!(sched.event_queues[&id].1.len(), 4);
810
811        // Zero drops
812        let counts = sched.drain_drop_counts();
813        assert!(counts.is_empty());
814    }
815
816    // ---- issue #2212: log_correlation_drop must also fire for ZenohInput ----
817
818    fn make_zenoh_input(id: &str, params: MetadataParameters) -> EventItem {
819        let ts = uhlc::HLC::default().new_timestamp();
820        let metadata = Metadata::from_parameters(ts, params);
821        use dora_arrow_convert::IntoArrow;
822        EventItem::ZenohInput {
823            id: DataId::from(id.to_string()),
824            metadata: std::sync::Arc::new(metadata),
825            data: dora_arrow_convert::internal::into_array_ref(().into_arrow()).to_data(),
826        }
827    }
828
829    /// Helper: extract request_id from a ZenohInput event's metadata, if any.
830    fn request_id_of_zenoh(event: &EventItem) -> Option<String> {
831        let EventItem::ZenohInput { metadata, .. } = event else {
832            return None;
833        };
834        get_string_param(&metadata.parameters, REQUEST_ID).map(|s| s.to_string())
835    }
836
837    #[test]
838    fn zenoh_drop_oldest_drops_front_loudly_when_both_queue_and_incoming_are_correlated() {
839        // Mirrors `drop_oldest_drops_front_loudly_when_both_queue_and_incoming_are_correlated`
840        // but uses ZenohInput items, exercising the second match arm of
841        // `log_correlation_drop` (issue #2212).
842        //
843        // Queue: [req-1, req-2] (both ZenohInput + correlated). Incoming is req-3.
844        // Unavoidable drop — the oldest correlation (req-1) is evicted.
845        let (mut sched, id) = make_scheduler(2);
846
847        sched.add_event(make_zenoh_input("audio", with_request_id("req-1")));
848        sched.add_event(make_zenoh_input("audio", with_request_id("req-2")));
849        sched.add_event(make_zenoh_input("audio", with_request_id("req-3")));
850
851        let queue = &sched.event_queues[&id].1;
852        assert_eq!(queue.len(), 2);
853        let ids: Vec<_> = queue.iter().filter_map(request_id_of_zenoh).collect();
854        assert_eq!(ids, vec!["req-2".to_string(), "req-3".to_string()]);
855    }
856
857    #[test]
858    fn zenoh_drop_oldest_preserves_correlated_when_non_correlated_present() {
859        // ZenohInput mirror of `drop_oldest_preserves_correlated_when_non_correlated_present`.
860        // A correlated ZenohInput must not be evicted when non-correlated items are available.
861        let (mut sched, id) = make_scheduler(3);
862
863        sched.add_event(make_zenoh_input("audio", with_request_id("req-1")));
864        sched.add_event(make_zenoh_input("audio", MetadataParameters::new()));
865        sched.add_event(make_zenoh_input("audio", MetadataParameters::new()));
866        sched.add_event(make_zenoh_input("audio", MetadataParameters::new()));
867
868        let queue = &sched.event_queues[&id].1;
869        assert_eq!(queue.len(), 3);
870        assert!(
871            queue
872                .iter()
873                .any(|e| request_id_of_zenoh(e).as_deref() == Some("req-1")),
874            "correlated ZenohInput was dropped even though non-correlated events were available"
875        );
876    }
877}