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