saluki_core/topology/interconnect/
dispatcher.rs

1use std::{borrow::Cow, time::Instant};
2
3use saluki_common::collections::FastHashMap;
4use saluki_error::{generic_error, GenericError};
5use saluki_metrics::{static_metrics, Counter, Histogram};
6use tokio::sync::mpsc;
7
8use super::Dispatchable;
9use crate::{components::ComponentContext, topology::OutputName};
10
11// TODO: When we have support for additional static labels on a per-metric basis, add `discard_reason` to
12// `events_discarded_total` metric to indicate that it's due to the destination component being disconnected.
13#[static_metrics(prefix = component, labels(component_id, component_type, output))]
14#[derive(Clone)]
15struct DispatcherMetrics {
16    events_sent_total: Counter,
17    #[metric(level = trace)]
18    send_latency_seconds: Histogram,
19    events_discarded_total: Counter,
20}
21
22impl DispatcherMetrics {
23    fn default_output(context: ComponentContext) -> Self {
24        Self::with_output_name(context, "_default")
25    }
26
27    fn named_output(context: ComponentContext, output_name: &str) -> Self {
28        Self::with_output_name(context, output_name)
29    }
30
31    fn with_output_name(context: ComponentContext, output_name: &str) -> Self {
32        Self::new(context.component_id(), context.component_type().as_str(), output_name)
33    }
34}
35
36/// A type that can be used as a buffer for dispatching items.
37pub trait DispatchBuffer: Dispatchable + Default {
38    /// Type of item that can be pushed into the buffer.
39    type Item;
40
41    /// Returns the number of items currently in the buffer.
42    fn len(&self) -> usize;
43
44    /// Returns `true` if the buffer is full.
45    fn is_full(&self) -> bool;
46
47    /// Attempts to push an item into the buffer.
48    ///
49    /// Returns `Some(item)` if the buffer is full and the item couldn't be pushed.
50    fn try_push(&mut self, item: Self::Item) -> Option<Self::Item>;
51}
52
53struct DispatchTarget<T> {
54    metrics: DispatcherMetrics,
55    senders: Vec<mpsc::Sender<T>>,
56}
57
58impl<T> DispatchTarget<T>
59where
60    T: Dispatchable,
61{
62    fn default_output(context: ComponentContext) -> Self {
63        Self {
64            metrics: DispatcherMetrics::default_output(context),
65            senders: Vec::new(),
66        }
67    }
68
69    fn named_output(context: ComponentContext, output_name: &str) -> Self {
70        Self {
71            metrics: DispatcherMetrics::named_output(context, output_name),
72            senders: Vec::new(),
73        }
74    }
75
76    fn add_sender(&mut self, sender: mpsc::Sender<T>) {
77        self.senders.push(sender);
78    }
79
80    async fn send(&self, item: T) -> Result<(), GenericError> {
81        if self.senders.is_empty() {
82            // Track discarded events when no senders are attached to this output
83            let item_count = item.item_count() as u64;
84            self.metrics.events_discarded_total().increment(item_count);
85            // Anchor the legitimate zero-sender discard. A wired edge never reaches this branch, so this stays a
86            // disconnected-output signal — not a silent-loss-on-a-wired-edge violation.
87            saluki_antithesis::sometimes!(
88                true,
89                "events discarded on a zero-sender output",
90                { "items": item_count }
91            );
92            return Ok(());
93        }
94
95        let start = Instant::now();
96        let item_count = item.item_count();
97
98        // Send the item to all senders except the last one by cloning the item.
99        saluki_antithesis::always_gt!(self.senders.len(), 0, "dispatcher fanout has at least one sender");
100        let cloned_sends = self.senders.len() - 1;
101        for sender in &self.senders[0..cloned_sends] {
102            sender
103                .send(item.clone())
104                .await
105                .map_err(|_| generic_error!("Failed to send to output."))?;
106        }
107
108        // Send the item to the last sender without cloning.
109        let last_sender = &self.senders[cloned_sends];
110        last_sender
111            .send(item)
112            .await
113            .map_err(|_| generic_error!("Failed to send to output."))?;
114
115        let elapsed = start.elapsed();
116
117        // TODO: We should consider splitting this out per-sender somehow. We would need to carry around the
118        // destination component's ID, though, to properly associate it.
119        self.metrics.send_latency_seconds().record(elapsed);
120
121        let total_events_sent = (self.senders.len() * item_count) as u64;
122        self.metrics.events_sent_total().increment(total_events_sent);
123
124        Ok(())
125    }
126}
127
128/// A buffered dispatcher.
129///
130/// `BufferedDispatcher` provides an efficient and ergonomic interface to `Dispatcher` that allows for writing events
131/// one-by-one into batches, which are then dispatched to the configured output as needed. This allows callers to focus
132/// on the logic around what items to send, without needing to worry about the details of event buffer sizing or
133/// flushing.
134pub struct BufferedDispatcher<'a, T> {
135    metrics: &'a DispatcherMetrics,
136    flushed_len: usize,
137    buffer: Option<T>,
138    target: &'a DispatchTarget<T>,
139}
140
141impl<'a, T> BufferedDispatcher<'a, T> {
142    fn new(target: &'a DispatchTarget<T>) -> Self {
143        Self {
144            metrics: &target.metrics,
145            flushed_len: 0,
146            buffer: None,
147            target,
148        }
149    }
150}
151
152impl<T> BufferedDispatcher<'_, T>
153where
154    T: DispatchBuffer,
155{
156    async fn try_flush_buffer(&self, buffer: T) -> Result<(), GenericError> {
157        let buffer_len = buffer.len();
158        if buffer_len > 0 {
159            self.target.send(buffer).await
160        } else {
161            Ok(())
162        }
163    }
164
165    /// Pushes an item into the buffered dispatcher.
166    ///
167    /// # Errors
168    ///
169    /// If there is an error flushing items to the output, or if there is an error acquiring a new buffer, an error
170    /// is returned.
171    pub async fn push(&mut self, item: T::Item) -> Result<(), GenericError> {
172        // If our current buffer is full, flush it before acquiring a new one.
173        if let Some(old_buffer) = self.buffer.take_if(|b| b.is_full()) {
174            self.try_flush_buffer(old_buffer).await?;
175        }
176
177        // Add the item to our current buffer.
178        //
179        // If our current buffer is empty, create a new one first. If the current buffer is full, return an error
180        // because it should be impossible to get a new buffer that is full.
181        let buffer = self.buffer.get_or_insert_default();
182        if buffer.try_push(item).is_some() {
183            return Err(generic_error!("Dispatch buffer already full after acquisition."));
184        }
185
186        self.flushed_len += 1;
187
188        Ok(())
189    }
190
191    /// Consumes this buffered dispatcher and sends/flushes all input items to the underlying output.
192    ///
193    /// If flushing is successful, `Ok(flushed)` is returned, where `flushed` is the total number of items that
194    /// have been flushed through this buffered dispatcher.
195    ///
196    /// # Errors
197    ///
198    /// If there is an error sending items to the output, an error is returned.
199    pub async fn send_all<I>(mut self, items: I) -> Result<usize, GenericError>
200    where
201        I: IntoIterator<Item = T::Item>,
202    {
203        for item in items {
204            self.push(item).await?;
205        }
206
207        self.flush().await
208    }
209
210    /// Consumes this buffered dispatcher, flushing any buffered items to the underlying output.
211    ///
212    /// If flushing is successful, `Ok(flushed)` is returned, where `flushed` is the total number of items that have
213    /// been flushed through this buffered dispatcher.
214    ///
215    /// # Errors
216    ///
217    /// If there is an error sending items to the output, an error is returned.
218    pub async fn flush(mut self) -> Result<usize, GenericError> {
219        if let Some(old_buffer) = self.buffer.take() {
220            self.try_flush_buffer(old_buffer).await?;
221        }
222
223        // We increment the "events sent" metric here because we want to count the number of buffered items, vs doing it in
224        // `DispatchTarget::send` where all it knows is that it sent one item.
225        self.metrics.events_sent_total().increment(self.flushed_len as u64);
226
227        Ok(self.flushed_len)
228    }
229}
230
231/// Dispatches items from one component to another.
232///
233/// [`Dispatcher`] provides an ergonomic interface for sending items to a downstream component. It has support for
234/// multiple outputs (a default output, and additional "named" outputs) and provides telemetry around the number of
235/// dispatched items as well as the latency of sending them.
236pub struct Dispatcher<T>
237where
238    T: Dispatchable,
239{
240    context: ComponentContext,
241    default: Option<DispatchTarget<T>>,
242    targets: FastHashMap<Cow<'static, str>, DispatchTarget<T>>,
243}
244
245impl<T> Dispatcher<T>
246where
247    T: Dispatchable,
248{
249    /// Create a new `Dispatcher` for the given component context.
250    pub fn new(context: ComponentContext) -> Self {
251        Self {
252            context,
253            default: None,
254            targets: FastHashMap::default(),
255        }
256    }
257
258    /// Adds an output to the dispatcher.
259    ///
260    /// # Errors
261    ///
262    /// If the output already exists, an error is returned.
263    pub fn add_output(&mut self, output_name: OutputName) -> Result<(), GenericError> {
264        match output_name {
265            OutputName::Default => {
266                if self.default.is_some() {
267                    return Err(generic_error!("Default output already exists."));
268                }
269
270                self.default = Some(DispatchTarget::default_output(self.context.clone()));
271            }
272            OutputName::Given(name) => {
273                if self.targets.contains_key(&name) {
274                    return Err(generic_error!("Output '{}' already exists.", name));
275                }
276                let target = DispatchTarget::named_output(self.context.clone(), &name);
277                self.targets.insert(name, target);
278            }
279        }
280
281        Ok(())
282    }
283
284    /// Attaches a sender to the given output.
285    ///
286    /// # Errors
287    ///
288    /// If the output doesn't exist, an error is returned.
289    pub fn attach_sender_to_output(
290        &mut self, output_name: &OutputName, sender: mpsc::Sender<T>,
291    ) -> Result<(), GenericError> {
292        let target = match output_name {
293            OutputName::Default => self
294                .default
295                .as_mut()
296                .ok_or_else(|| generic_error!("No default output declared."))?,
297            OutputName::Given(name) => self
298                .targets
299                .get_mut(name)
300                .ok_or_else(|| generic_error!("Output '{}' does not exist.", name))?,
301        };
302        target.add_sender(sender);
303
304        Ok(())
305    }
306
307    fn get_default_output(&self) -> Result<&DispatchTarget<T>, GenericError> {
308        self.default
309            .as_ref()
310            .ok_or_else(|| generic_error!("No default output declared."))
311    }
312
313    fn get_named_output(&self, name: &str) -> Result<&DispatchTarget<T>, GenericError> {
314        self.targets
315            .get(name)
316            .ok_or_else(|| generic_error!("No output named '{}' declared.", name))
317    }
318
319    /// Returns `true` if the default output is connected to downstream components.
320    pub fn is_default_output_connected(&self) -> bool {
321        self.default.as_ref().is_some_and(|target| !target.senders.is_empty())
322    }
323
324    /// Returns `true` if the named output is connected to downstream components.
325    pub fn is_named_output_connected(&self, name: &str) -> bool {
326        self.targets.get(name).is_some_and(|target| !target.senders.is_empty())
327    }
328
329    /// Dispatches the given item to the default output.
330    ///
331    /// # Errors
332    ///
333    /// If the default output isn't set, or there is an error sending to the default output, an error is returned.
334    pub async fn dispatch(&self, item: T) -> Result<(), GenericError> {
335        self.dispatch_inner(None, item).await
336    }
337
338    /// Dispatches the given items to the given named output.
339    ///
340    /// # Errors
341    ///
342    /// If a output of the given name isn't set, or there is an error sending to the output, an error is returned.
343    pub async fn dispatch_named<N>(&self, output_name: N, item: T) -> Result<(), GenericError>
344    where
345        N: AsRef<str>,
346    {
347        self.dispatch_inner(Some(output_name.as_ref()), item).await
348    }
349
350    async fn dispatch_inner(&self, output_name: Option<&str>, item: T) -> Result<(), GenericError> {
351        let target = match output_name {
352            None => self.get_default_output()?,
353            Some(name) => self.get_named_output(name)?,
354        };
355
356        target.send(item).await?;
357
358        Ok(())
359    }
360}
361
362impl<T> Dispatcher<T>
363where
364    T: DispatchBuffer,
365{
366    /// Creates a buffered dispatcher for the default output.
367    ///
368    /// This should generally be used if the items being dispatched aren't already collected in a container, or exposed
369    /// via an iterable type. It allows for efficiently buffering items one-by-one before dispatching them to the
370    /// underlying output.
371    ///
372    /// # Errors
373    ///
374    /// If the default output hasn't been configured, an error will be returned.
375    pub fn buffered(&self) -> Result<BufferedDispatcher<'_, T>, GenericError> {
376        self.get_default_output().map(BufferedDispatcher::new)
377    }
378
379    /// Creates a buffered dispatcher for the given named output.
380    ///
381    /// This should generally be used if the items being dispatched aren't already collected in a container, or exposed
382    /// via an iterable type. It allows for efficiently buffering items one-by-one before dispatching them to the
383    /// underlying output.
384    ///
385    /// # Errors
386    ///
387    /// If the given named output hasn't been configured, an error will be returned.
388    pub fn buffered_named<N>(&self, output_name: N) -> Result<BufferedDispatcher<'_, T>, GenericError>
389    where
390        N: AsRef<str>,
391    {
392        self.get_named_output(output_name.as_ref()).map(BufferedDispatcher::new)
393    }
394
395    /// Dispatches a single item to the default output.
396    ///
397    /// # Errors
398    ///
399    /// If the default output isn't set, or there is an error sending to the default output, an error is returned.
400    pub async fn dispatch_one(&self, item: T::Item) -> Result<(), GenericError> {
401        self.dispatch_one_inner(None, item).await
402    }
403
404    /// Dispatches a single item to the given named output.
405    ///
406    /// # Errors
407    ///
408    /// If an output of the given name isn't set, or there is an error sending to the output, an error is returned.
409    pub async fn dispatch_one_named<N>(&self, output_name: N, item: T::Item) -> Result<(), GenericError>
410    where
411        N: AsRef<str>,
412    {
413        self.dispatch_one_inner(Some(output_name.as_ref()), item).await
414    }
415
416    async fn dispatch_one_inner(&self, output_name: Option<&str>, item: T::Item) -> Result<(), GenericError> {
417        let target = match output_name {
418            None => self.get_default_output()?,
419            Some(name) => self.get_named_output(name)?,
420        };
421
422        let mut buffer = T::default();
423        if buffer.try_push(item).is_some() {
424            return Err(generic_error!("Default-constructed buffer rejected a single item."));
425        }
426        target.send(buffer).await
427    }
428}
429
430#[cfg(test)]
431mod tests {
432    // TODO: Tests asserting we emit metrics, and the right metrics.
433
434    use std::ops::Deref;
435
436    use metrics::{Key, Label};
437    use metrics_util::{
438        debugging::{DebugValue, DebuggingRecorder, Snapshotter},
439        CompositeKey, MetricKind,
440    };
441    use ordered_float::OrderedFloat;
442
443    use super::*;
444
445    #[derive(Clone, Copy, Debug, Eq, PartialEq)]
446    struct SingleEvent<T>(T);
447
448    impl<T: Clone + Copy> Dispatchable for SingleEvent<T> {
449        fn item_count(&self) -> usize {
450            1
451        }
452    }
453
454    impl<T: Clone + Copy> From<T> for SingleEvent<T> {
455        fn from(value: T) -> Self {
456            Self(value)
457        }
458    }
459
460    #[derive(Clone, Debug, Eq, PartialEq)]
461    struct FixedUsizeVec<const N: usize> {
462        data: [usize; N],
463        len: usize,
464    }
465
466    impl<const N: usize> Default for FixedUsizeVec<N> {
467        fn default() -> Self {
468            Self { data: [0; N], len: 0 }
469        }
470    }
471
472    impl<const N: usize> Deref for FixedUsizeVec<N> {
473        type Target = [usize];
474
475        fn deref(&self) -> &Self::Target {
476            &self.data
477        }
478    }
479
480    impl<const N: usize> Dispatchable for FixedUsizeVec<N> {
481        fn item_count(&self) -> usize {
482            self.len
483        }
484    }
485
486    impl<const N: usize> DispatchBuffer for FixedUsizeVec<N> {
487        type Item = usize;
488
489        fn len(&self) -> usize {
490            self.len
491        }
492
493        fn is_full(&self) -> bool {
494            self.len == N
495        }
496
497        fn try_push(&mut self, item: Self::Item) -> Option<Self::Item> {
498            if self.is_full() {
499                Some(item)
500            } else {
501                self.data[self.len] = item;
502                self.len += 1;
503                None
504            }
505        }
506    }
507
508    fn unbuffered_dispatcher<T: Dispatchable>() -> Dispatcher<T> {
509        let component_context = ComponentContext::test_source("dispatcher_test");
510        Dispatcher::new(component_context)
511    }
512
513    fn buffered_dispatcher<T: DispatchBuffer>() -> Dispatcher<T> {
514        unbuffered_dispatcher()
515    }
516
517    /// One output-kind case: the output to operate on, plus the `output` metric label it maps to.
518    ///
519    /// Every dispatcher operation must behave identically for the default output and for a named output, so the tests
520    /// below iterate over these two cases rather than duplicating a default-vs-named function pair for each scenario.
521    struct OutputCase {
522        output: OutputName,
523        metric_label: &'static str,
524    }
525
526    fn output_cases() -> [OutputCase; 2] {
527        [
528            OutputCase {
529                output: OutputName::Default,
530                metric_label: "_default",
531            },
532            OutputCase {
533                output: OutputName::Given("special".into()),
534                metric_label: "special",
535            },
536        ]
537    }
538
539    /// Declares `output` on the dispatcher and attaches the given senders to it.
540    fn add_output_with_senders<T: Dispatchable, const N: usize>(
541        dispatcher: &mut Dispatcher<T>, output: &OutputName, senders: [mpsc::Sender<T>; N],
542    ) {
543        dispatcher
544            .add_output(output.clone())
545            .expect("output should not be declared yet");
546        for sender in senders {
547            dispatcher
548                .attach_sender_to_output(output, sender)
549                .expect("output should exist after being declared");
550        }
551    }
552
553    /// Dispatches `item` to `output`, choosing the default or named entry point as appropriate.
554    async fn dispatch_to<T: Dispatchable>(
555        dispatcher: &Dispatcher<T>, output: &OutputName, item: T,
556    ) -> Result<(), GenericError> {
557        match output {
558            OutputName::Default => dispatcher.dispatch(item).await,
559            OutputName::Given(name) => dispatcher.dispatch_named(name.as_ref(), item).await,
560        }
561    }
562
563    /// Creates a buffered dispatcher for `output`, choosing the default or named entry point as appropriate.
564    fn buffered_for<'a, T: DispatchBuffer>(
565        dispatcher: &'a Dispatcher<T>, output: &OutputName,
566    ) -> Result<BufferedDispatcher<'a, T>, GenericError> {
567        match output {
568            OutputName::Default => dispatcher.buffered(),
569            OutputName::Given(name) => dispatcher.buffered_named(name.as_ref()),
570        }
571    }
572
573    /// Dispatches a single item to `output`, choosing the default or named entry point as appropriate.
574    async fn dispatch_one_to<T: DispatchBuffer>(
575        dispatcher: &Dispatcher<T>, output: &OutputName, item: T::Item,
576    ) -> Result<(), GenericError> {
577        match output {
578            OutputName::Default => dispatcher.dispatch_one(item).await,
579            OutputName::Given(name) => dispatcher.dispatch_one_named(name.as_ref(), item).await,
580        }
581    }
582
583    /// Returns whether `output` is connected, choosing the default or named query as appropriate.
584    fn is_output_connected<T: Dispatchable>(dispatcher: &Dispatcher<T>, output: &OutputName) -> bool {
585        match output {
586            OutputName::Default => dispatcher.is_default_output_connected(),
587            OutputName::Given(name) => dispatcher.is_named_output_connected(name.as_ref()),
588        }
589    }
590
591    fn get_dispatcher_metric_ckey(
592        kind: MetricKind, name: &'static str, output_name: &'static str, tags: &[(&'static str, &'static str)],
593    ) -> CompositeKey {
594        let mut labels = vec![
595            Label::from_static_parts("component_id", "dispatcher_test"),
596            Label::from_static_parts("component_type", "source"),
597            Label::from_static_parts("output", output_name),
598        ];
599
600        for tag in tags {
601            labels.push(Label::from_static_parts(tag.0, tag.1));
602        }
603
604        let key = Key::from_parts(name, labels);
605        CompositeKey::new(kind, key)
606    }
607
608    fn get_output_metrics(snapshotter: &Snapshotter, output_name: &'static str) -> (u64, u64, Vec<OrderedFloat<f64>>) {
609        let events_sent_key = get_dispatcher_metric_ckey(
610            MetricKind::Counter,
611            DispatcherMetrics::events_sent_total_name(),
612            output_name,
613            &[],
614        );
615        let events_discarded_key = get_dispatcher_metric_ckey(
616            MetricKind::Counter,
617            DispatcherMetrics::events_discarded_total_name(),
618            output_name,
619            &[],
620        );
621        let send_latency_key = get_dispatcher_metric_ckey(
622            MetricKind::Histogram,
623            DispatcherMetrics::send_latency_seconds_name(),
624            output_name,
625            &[],
626        );
627
628        // TODO: This API for querying the metrics really sucks... and we need something better.
629        let current_metrics = snapshotter.snapshot().into_hashmap();
630        let (_, _, events_sent) = current_metrics
631            .get(&events_sent_key)
632            .expect("should have events sent metric");
633        let (_, _, events_discarded) = current_metrics
634            .get(&events_discarded_key)
635            .expect("should have events discarded metric");
636        let (_, _, send_latency) = current_metrics
637            .get(&send_latency_key)
638            .expect("should have send latency metric");
639
640        let events_sent = match events_sent {
641            DebugValue::Counter(value) => *value,
642            _ => panic!("unexpected metric type for events sent"),
643        };
644
645        let events_discarded = match events_discarded {
646            DebugValue::Counter(value) => *value,
647            _ => panic!("unexpected metric type for events discarded"),
648        };
649
650        let send_latency = match send_latency {
651            DebugValue::Histogram(value) => value.clone(),
652            _ => panic!("unexpected metric type for send latency"),
653        };
654
655        (events_sent, events_discarded, send_latency)
656    }
657
658    #[tokio::test]
659    async fn dispatch_delivers_item_to_each_output_kind() {
660        // Dispatching a single item to a wired output delivers it unchanged, for both the default and a named output.
661        for case in output_cases() {
662            let mut dispatcher = unbuffered_dispatcher::<SingleEvent<usize>>();
663            let (tx, mut rx) = mpsc::channel(1);
664            add_output_with_senders(&mut dispatcher, &case.output, [tx]);
665
666            let input_item = 42.into();
667            dispatch_to(&dispatcher, &case.output, input_item).await.unwrap();
668
669            let output_item = rx.try_recv().expect("input item should have been dispatched");
670            assert_eq!(output_item, input_item);
671        }
672    }
673
674    #[tokio::test]
675    async fn dispatch_fans_out_to_all_senders_for_each_output_kind() {
676        // With multiple senders attached to an output, a dispatched item is delivered (cloned) to every sender.
677        for case in output_cases() {
678            let mut dispatcher = unbuffered_dispatcher::<SingleEvent<usize>>();
679            let (tx1, mut rx1) = mpsc::channel(1);
680            let (tx2, mut rx2) = mpsc::channel(1);
681            add_output_with_senders(&mut dispatcher, &case.output, [tx1, tx2]);
682
683            let input_item = 42.into();
684            dispatch_to(&dispatcher, &case.output, input_item).await.unwrap();
685
686            assert_eq!(rx1.try_recv().expect("first sender should receive"), input_item);
687            assert_eq!(rx2.try_recv().expect("second sender should receive"), input_item);
688        }
689    }
690
691    #[tokio::test]
692    async fn dispatch_to_unconfigured_output_errors() {
693        // Dispatching to an output that was never declared fails with the documented error for that output kind.
694        for case in output_cases() {
695            let dispatcher = unbuffered_dispatcher::<SingleEvent<()>>();
696            let err = dispatch_to(&dispatcher, &case.output, ().into())
697                .await
698                .expect_err("dispatch to an unconfigured output must fail");
699            let msg = err.to_string();
700            match &case.output {
701                OutputName::Default => assert_eq!(msg, "No default output declared."),
702                OutputName::Given(_) => assert!(msg.contains("No output named"), "got: {msg}"),
703            }
704        }
705    }
706
707    #[tokio::test]
708    async fn buffered_dispatch_flushes_partial_buffer() {
709        // A single buffered push, once flushed, arrives as a one-element buffer on the wired output.
710        for case in output_cases() {
711            let mut dispatcher = buffered_dispatcher::<FixedUsizeVec<4>>();
712            let (tx, mut rx) = mpsc::channel(1);
713            add_output_with_senders(&mut dispatcher, &case.output, [tx]);
714
715            let input_item = 42;
716            let mut buffered = buffered_for(&dispatcher, &case.output).unwrap();
717            buffered.push(input_item).await.unwrap();
718            let flushed_len = buffered.flush().await.unwrap();
719            assert_eq!(flushed_len, 1);
720
721            let output_item = rx.try_recv().expect("input item should have been dispatched");
722            assert_eq!(output_item.len(), 1);
723            assert_eq!(output_item[0], input_item);
724        }
725    }
726
727    #[tokio::test]
728    async fn buffered_dispatch_flushes_full_buffer_during_push() {
729        // Pushing more items than a single buffer can hold flushes the full buffer mid-push, so the items arrive as
730        // successive buffers (four, then the remaining two).
731        for case in output_cases() {
732            let mut dispatcher = buffered_dispatcher::<FixedUsizeVec<4>>();
733            let (tx, mut rx) = mpsc::channel(2);
734            add_output_with_senders(&mut dispatcher, &case.output, [tx]);
735
736            let input_items: Vec<usize> = vec![1, 2, 3, 4, 5, 6];
737            let mut buffered = buffered_for(&dispatcher, &case.output).unwrap();
738            for item in &input_items {
739                buffered.push(*item).await.unwrap();
740            }
741            let flushed_len = buffered.flush().await.unwrap();
742            assert_eq!(flushed_len, input_items.len());
743
744            let first = rx.try_recv().expect("first buffer should have been dispatched");
745            assert_eq!(first.len(), 4);
746            assert_eq!(first[0..4], input_items[0..4]);
747
748            let second = rx.try_recv().expect("second buffer should have been dispatched");
749            assert_eq!(second.len(), 2);
750            assert_eq!(second[0..2], input_items[4..6]);
751        }
752    }
753
754    #[tokio::test]
755    async fn buffered_dispatch_fans_out_partial_buffer() {
756        // A buffered push flushed to an output with multiple senders is delivered to every sender.
757        for case in output_cases() {
758            let mut dispatcher = buffered_dispatcher::<FixedUsizeVec<4>>();
759            let (tx1, mut rx1) = mpsc::channel(1);
760            let (tx2, mut rx2) = mpsc::channel(1);
761            add_output_with_senders(&mut dispatcher, &case.output, [tx1, tx2]);
762
763            let input_item = 42;
764            let mut buffered = buffered_for(&dispatcher, &case.output).unwrap();
765            buffered.push(input_item).await.unwrap();
766            let flushed_len = buffered.flush().await.unwrap();
767            assert_eq!(flushed_len, 1);
768
769            let out1 = rx1.try_recv().expect("first sender should receive");
770            assert_eq!(out1.len(), 1);
771            assert_eq!(out1[0], input_item);
772
773            let out2 = rx2.try_recv().expect("second sender should receive");
774            assert_eq!(out2.len(), 1);
775            assert_eq!(out2[0], input_item);
776        }
777    }
778
779    #[tokio::test]
780    async fn dispatch_to_output_without_senders_succeeds() {
781        // Dispatching to a declared output that has no senders attached succeeds (the item is discarded, not an error).
782        for case in output_cases() {
783            let mut dispatcher = unbuffered_dispatcher::<SingleEvent<u32>>();
784            dispatcher
785                .add_output(case.output.clone())
786                .expect("should be able to declare the output");
787
788            dispatch_to(&dispatcher, &case.output, 42.into())
789                .await
790                .expect("dispatch to a senderless output should succeed");
791        }
792    }
793
794    #[tokio::test]
795    async fn disconnected_output_records_discarded_events() {
796        // Dispatching to a declared-but-senderless output records the item count as discarded (and none as sent, with
797        // no send-latency samples), for both the default and a named output.
798        for case in output_cases() {
799            let recorder = DebuggingRecorder::new();
800            let snapshotter = recorder.snapshotter();
801            let output = case.output.clone();
802            let dispatcher = metrics::with_local_recorder(&recorder, || {
803                let mut dispatcher = buffered_dispatcher::<FixedUsizeVec<4>>();
804                dispatcher
805                    .add_output(output)
806                    .expect("should not fail to declare the output");
807                dispatcher
808            });
809
810            // Dispatch an item with a count of 1 to the senderless output; it is discarded rather than sent.
811            let mut single_item = FixedUsizeVec::<4>::default();
812            assert_eq!(None, single_item.try_push(42));
813            let single_item_count = single_item.item_count() as u64;
814            dispatch_to(&dispatcher, &case.output, single_item)
815                .await
816                .expect("should not fail to dispatch");
817
818            let (events_sent, events_discarded, send_latencies) = get_output_metrics(&snapshotter, case.metric_label);
819            assert_eq!(events_sent, 0);
820            assert_eq!(events_discarded, single_item_count);
821            assert!(send_latencies.is_empty());
822
823            // Dispatch a second item, this time with a count of 3.
824            let mut multiple_items = FixedUsizeVec::<4>::default();
825            assert_eq!(None, multiple_items.try_push(42));
826            assert_eq!(None, multiple_items.try_push(12345));
827            assert_eq!(None, multiple_items.try_push(1337));
828            let multiple_items_count = multiple_items.item_count() as u64;
829            dispatch_to(&dispatcher, &case.output, multiple_items)
830                .await
831                .expect("should not fail to dispatch");
832
833            let (events_sent, events_discarded, send_latencies) = get_output_metrics(&snapshotter, case.metric_label);
834            assert_eq!(events_sent, 0);
835            assert_eq!(events_discarded, multiple_items_count);
836            assert!(send_latencies.is_empty());
837        }
838    }
839
840    #[tokio::test]
841    async fn output_connected_reflects_sender_attachment() {
842        // The connected-query reports false before an output exists, false once declared without senders, and true
843        // only after a sender is attached -- for both the default and a named output.
844        for case in output_cases() {
845            let mut dispatcher = unbuffered_dispatcher::<SingleEvent<u32>>();
846
847            assert!(
848                !is_output_connected(&dispatcher, &case.output),
849                "an undeclared output must report as not connected"
850            );
851
852            dispatcher
853                .add_output(case.output.clone())
854                .expect("should be able to declare the output");
855            assert!(
856                !is_output_connected(&dispatcher, &case.output),
857                "a declared output with no senders must report as not connected"
858            );
859
860            let (tx, _rx) = mpsc::channel(1);
861            dispatcher
862                .attach_sender_to_output(&case.output, tx)
863                .expect("should be able to attach a sender");
864            assert!(
865                is_output_connected(&dispatcher, &case.output),
866                "an output with a sender attached must report as connected"
867            );
868
869            // A query for a different, undeclared named output is always false.
870            assert!(
871                !dispatcher.is_named_output_connected("nonexistent_output"),
872                "an unknown named output must report as not connected"
873            );
874        }
875    }
876
877    #[tokio::test]
878    async fn dispatch_one_wraps_item_in_single_element_buffer() {
879        // `dispatch_one`/`dispatch_one_named` wrap a single item into a one-element buffer on the wired output.
880        for case in output_cases() {
881            let mut dispatcher = buffered_dispatcher::<FixedUsizeVec<4>>();
882            let (tx, mut rx) = mpsc::channel(1);
883            add_output_with_senders(&mut dispatcher, &case.output, [tx]);
884
885            let input_item = 42;
886            dispatch_one_to(&dispatcher, &case.output, input_item).await.unwrap();
887
888            let output_item = rx.try_recv().expect("input item should have been dispatched");
889            assert_eq!(output_item.len(), 1);
890            assert_eq!(output_item[0], input_item);
891        }
892    }
893
894    #[tokio::test]
895    async fn dispatch_one_to_unconfigured_output_errors() {
896        // `dispatch_one`/`dispatch_one_named` to an output that was never declared fails with the documented error for
897        // that output kind.
898        for case in output_cases() {
899            let dispatcher = buffered_dispatcher::<FixedUsizeVec<4>>();
900            let err = dispatch_one_to(&dispatcher, &case.output, 42)
901                .await
902                .expect_err("dispatch_one to an unconfigured output must fail");
903            let msg = err.to_string();
904            match &case.output {
905                OutputName::Default => assert_eq!(msg, "No default output declared."),
906                OutputName::Given(_) => assert!(msg.contains("No output named"), "got: {msg}"),
907            }
908        }
909    }
910
911    #[tokio::test]
912    async fn add_output_rejects_duplicate() {
913        // `add_output`'s documented error: declaring the same output twice is rejected with an "already exists" error,
914        // for both the default and a named output.
915        for case in output_cases() {
916            let mut dispatcher = unbuffered_dispatcher::<SingleEvent<u32>>();
917            dispatcher
918                .add_output(case.output.clone())
919                .expect("first declaration should succeed");
920
921            let err = dispatcher
922                .add_output(case.output.clone())
923                .expect_err("declaring the same output twice must fail");
924            assert!(err.to_string().contains("already exists"), "got: {err}");
925        }
926    }
927
928    #[tokio::test]
929    async fn attach_sender_to_undeclared_output_errors() {
930        // `attach_sender_to_output`'s documented error: attaching a sender to an output that hasn't been declared fails
931        // with the documented error for that output kind.
932        for case in output_cases() {
933            let mut dispatcher = unbuffered_dispatcher::<SingleEvent<u32>>();
934            let (tx, _rx) = mpsc::channel(1);
935            let err = dispatcher
936                .attach_sender_to_output(&case.output, tx)
937                .expect_err("attaching to an undeclared output must fail");
938            let msg = err.to_string();
939            match &case.output {
940                OutputName::Default => assert_eq!(msg, "No default output declared."),
941                OutputName::Given(_) => assert!(msg.contains("does not exist"), "got: {msg}"),
942            }
943        }
944    }
945
946    #[tokio::test]
947    async fn dispatch_one_errors_when_buffer_cannot_hold_a_single_item() {
948        // Defensive guard in `dispatch_one_inner`: if the default-constructed buffer can't accept even one item, it
949        // returns a documented error rather than silently dropping the item. This branch is only reachable via a
950        // degenerate zero-capacity buffer type; every real buffer (capacity >= 1) accepts the first item.
951        let mut dispatcher = buffered_dispatcher::<FixedUsizeVec<0>>();
952        let (tx, _rx) = mpsc::channel(1);
953        add_output_with_senders(&mut dispatcher, &OutputName::Default, [tx]);
954
955        let err = dispatcher
956            .dispatch_one(42)
957            .await
958            .expect_err("a zero-capacity buffer must be rejected, not silently drop the item");
959        assert!(err.to_string().contains("rejected a single item"), "got: {err}");
960    }
961}