Skip to main content

media_plane/
byte_merge.rs

1//! [`ByteMerge`] — the one bounded multi-input primitive in the byte layer.
2//!
3//! # Why this exists, and why it is the only one
4//!
5//! Rev 1 of the media-plane architecture claimed there was no multi-input
6//! shape below the IR layer; rev 2 conceded that was already false — ST
7//! 2022-7 hitless (2-input) and RIST bonding (N-input) are on the roadmap,
8//! and rev 1 had *cited* 2022-7 to justify one-writer-per-`Trunk` while
9//! ignoring that its own inputs are multiple. `ByteMerge` is rev 2's honest
10//! answer: **N byte sources reduce to one byte stream at exactly one place**,
11//! the byte layer, via exactly one primitive. Every layer above this one
12//! (demux, IR transforms, `Trunk`) stays strictly single-input — if a third
13//! multi-input shape is ever needed above the byte layer, that is a sign this
14//! design is wrong and a real DAG is the answer, not a reason to bolt another
15//! ad-hoc merge point onto some other layer.
16//!
17//! # Messages, not a byte soup
18//!
19//! **`ByteMerge` operates on discrete messages (one [`bytes::Bytes`] unit per
20//! [`ByteMerge::feed`] call), not an undelimited byte stream.** Both policies
21//! implemented here need message boundaries to make sense of: [`MergePolicy::FirstArrival`]
22//! interleaves whole messages (there is no meaningful "first arrival" of a
23//! byte offset inside an undelimited stream), and [`MergePolicy::Failover`]
24//! forwards or drops whole messages depending on which source is currently
25//! active. A future deduplicating policy needs this even more directly — you
26//! cannot recognise two copies of the same datagram by comparing byte
27//! ranges, only by comparing whole messages (or, for ST 2022-7 specifically,
28//! RTP sequence numbers inside them; see below). The API shape enforces this
29//! itself: [`ByteMerge::feed`] takes one `Bytes` per call, not a `&[u8]`
30//! slice a caller could concatenate several messages into.
31//!
32//! # `Hitless2022_7` is deliberately absent, not stubbed
33//!
34//! SMPTE ST 2022-7 seamless switching dedupes two identical RTP streams by
35//! comparing RTP sequence numbers, selecting whichever copy of each sequence
36//! number arrives first and discarding the duplicate. Raw `Bytes` cannot
37//! express that: telling two arrivals apart as "the same sequence number"
38//! needs an RTP header parse and per-stream sequence-number bookkeeping this
39//! layer does not have (and should not grow just to make room for a variant —
40//! see the "no correct producer" note below). That work lands with #752,
41//! where it gets a real implementation. Until then, this crate's
42//! [`MergePolicy`] simply does not have a `Hitless2022_7` variant — it is not
43//! present as an empty/unimplemented arm, a `todo!()`, or a doc-only stub.
44//! This project has already been bitten more than once by shipping a variant
45//! with no correct producer behind it; the fix here is to not create another,
46//! rather than to add one and hope nobody constructs it. [`MergePolicy`] is
47//! `#[non_exhaustive]` specifically so adding `Hitless2022_7` later is
48//! additive, not a breaking change to every match arm in the workspace.
49//!
50//! # Bounding
51//!
52//! Both types in the byte layer eat remote input directly, and this
53//! project's unbounded-allocation incidents have all been in code doing
54//! exactly that. `ByteMerge` holds two kinds of state, both bounded
55//! independently of call volume:
56//!
57//! - **Per-source state** (`last_seen`) is a fixed-size array sized at
58//!   construction (`num_sources`) — flooding one source with any number of
59//!   `feed` calls updates one entry in place, it never grows the array.
60//! - **The output queue** is capped at `max_queued`; once full,
61//!   [`ByteMerge::feed`] rejects the call outright with
62//!   [`MergeError::QueueFull`] rather than growing past the cap or silently
63//!   evicting (unlike [`crate::ByteTap`] — a merge feeds a demux pipeline
64//!   that is expected to apply its own back-pressure via `demand()`
65//!   upstream, so there is no "producer must never see a rejection" contract
66//!   here the way there is for a tap).
67
68use alloc::collections::VecDeque;
69use alloc::vec;
70use alloc::vec::Vec;
71use broadcast_common::stage::Timestamp;
72use bytes::Bytes;
73use core::time::Duration;
74use thiserror::Error;
75
76/// Identifies one of the `N` sources feeding a [`ByteMerge`].
77///
78/// `ByteMerge` has no notion of what a source *is* — a UDP socket, an RTP
79/// session, a T2-MI PLP — only that it is one of `0..num_sources`, assigned
80/// by whoever constructs the merge and calls [`ByteMerge::feed`].
81#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
82pub struct SourceId(pub usize);
83
84/// Policy [`ByteMerge`] uses to reduce `N` sources to one output stream.
85///
86/// `#[non_exhaustive]`: see the [module docs](self) on why `Hitless2022_7`
87/// is deliberately not a variant here yet, and why that makes this attribute
88/// load-bearing rather than decorative — adding it in #752 must not break
89/// every exhaustive `match` in the workspace.
90#[derive(Debug, Clone, PartialEq, Eq)]
91#[non_exhaustive]
92pub enum MergePolicy {
93    /// Forward every message from every source, in the order [`ByteMerge::feed`]
94    /// is called, with no preference and no deduplication. The plain fan-in
95    /// case: whichever source's message arrives first is emitted first.
96    FirstArrival,
97    /// Prefer `primary`; forward `secondary`'s messages only while `primary`
98    /// is considered silent.
99    ///
100    /// # Silence detection
101    ///
102    /// `primary` is silent once `silence_timeout` has elapsed since the last
103    /// message `primary` produced, checked via [`ByteMerge::on_deadline`]
104    /// (there is no background timer — this crate is sans-IO, like
105    /// [`crate::ByteStage`], so a driver must call `on_deadline` itself; see
106    /// [`ByteMerge::next_deadline`]). A single message from `primary` — even
107    /// one that arrives close to the timeout — resets the silence clock, so
108    /// a merge does not switch away from a primary that is merely running
109    /// late rather than actually down. Before `primary` has produced its
110    /// first message at all, the merge treats it as active by default and
111    /// reports no deadline: a fresh merge has no evidence primary is
112    /// unhealthy, only that it has not started yet, and those are not the
113    /// same thing.
114    ///
115    /// # Switch-back rule
116    ///
117    /// **The instant `primary` produces a message again, it immediately
118    /// reclaims active status** — that message is forwarded, and any
119    /// subsequent `secondary` traffic is dropped until `primary` goes silent
120    /// again. This is chosen because "prefer a primary source" only means
121    /// something if the merge actually prefers it whenever it is alive, and
122    /// no separate hold-down timer is needed to avoid flapping on the
123    /// switch-*back* direction: the switch *away* from primary already
124    /// required a full `silence_timeout` of no traffic, so a real message
125    /// arriving is evidence the source recovered, not jitter — the flapping
126    /// this project cares about (switching away on a single late packet) is
127    /// what the silence-clock reset above already prevents.
128    Failover {
129        /// The source [`ByteMerge`] prefers whenever it is producing.
130        primary: SourceId,
131        /// The source forwarded only while `primary` is silent.
132        secondary: SourceId,
133        /// How long `primary` must be silent before `secondary` takes over.
134        silence_timeout: Duration,
135    },
136    // `Hitless2022_7` intentionally NOT a variant — see the module docs.
137}
138
139/// Errors [`ByteMerge::feed`] can return.
140#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
141#[non_exhaustive]
142pub enum MergeError {
143    /// `source` is not one of `0..num_sources` for this merge.
144    #[error("source {source_id:?} is out of range for a merge with {num_sources} sources")]
145    UnknownSource {
146        /// The out-of-range source that was fed.
147        source_id: SourceId,
148        /// How many sources this merge was constructed with.
149        num_sources: usize,
150    },
151    /// The bounded output queue already holds `max_queued` messages awaiting
152    /// [`ByteMerge::poll`]; the call was rejected outright and nothing from
153    /// it was buffered.
154    #[error("merge output queue full: {max_queued} messages already queued for poll()")]
155    QueueFull {
156        /// The configured queue bound.
157        max_queued: usize,
158    },
159}
160
161/// The one bounded multi-input primitive in the byte layer: `N` byte sources
162/// reduced to one output stream. See the [module docs](self).
163pub struct ByteMerge {
164    policy: MergePolicy,
165    num_sources: usize,
166    /// Last arrival time seen from each source, indexed by `SourceId.0`.
167    /// Fixed-size at construction — bounded per-source state (module docs).
168    last_seen: Vec<Option<Timestamp>>,
169    /// Which source `Failover` currently forwards; meaningless (unused)
170    /// under `FirstArrival`.
171    active: usize,
172    queue: VecDeque<(Bytes, Timestamp)>,
173    max_queued: usize,
174}
175
176impl ByteMerge {
177    /// Construct a merge for `num_sources` sources (`0..num_sources`),
178    /// applying `policy`, with its output queue bounded to `max_queued`
179    /// messages.
180    ///
181    /// Panics if `num_sources == 0`, `max_queued == 0`, or (for
182    /// [`MergePolicy::Failover`]) `primary`/`secondary` are not both within
183    /// `0..num_sources` — all three are construction-time configuration
184    /// mistakes, not remote input, so they panic rather than returning a
185    /// `Result` a caller could ignore.
186    pub fn new(policy: MergePolicy, num_sources: usize, max_queued: usize) -> Self {
187        assert!(num_sources > 0, "ByteMerge num_sources must be > 0");
188        assert!(max_queued > 0, "ByteMerge max_queued must be > 0");
189        let active = match &policy {
190            MergePolicy::Failover {
191                primary, secondary, ..
192            } => {
193                assert!(
194                    primary.0 < num_sources && secondary.0 < num_sources,
195                    "ByteMerge Failover primary/secondary must be within 0..num_sources"
196                );
197                primary.0
198            }
199            MergePolicy::FirstArrival => 0,
200        };
201        ByteMerge {
202            policy,
203            num_sources,
204            last_seen: vec![None; num_sources],
205            active,
206            queue: VecDeque::new(),
207            max_queued,
208        }
209    }
210
211    /// How many sources this merge accepts `feed` calls from.
212    pub fn num_sources(&self) -> usize {
213        self.num_sources
214    }
215
216    /// Messages currently queued, awaiting [`ByteMerge::poll`]. Never
217    /// exceeds the `max_queued` bound this merge was constructed with.
218    pub fn len(&self) -> usize {
219        self.queue.len()
220    }
221
222    /// `true` if no messages are currently queued.
223    pub fn is_empty(&self) -> bool {
224        self.queue.is_empty()
225    }
226
227    /// Feed one discrete message from `source`, observed at `at`.
228    ///
229    /// Whether it is forwarded to [`ByteMerge::poll`] depends on the policy:
230    /// every message is forwarded under [`MergePolicy::FirstArrival`]; under
231    /// [`MergePolicy::Failover`] only the currently-active source's messages
232    /// are (see that variant's docs for exactly when that is, and the
233    /// switch-back rule).
234    ///
235    /// Returns [`MergeError::UnknownSource`] if `source` is out of range, or
236    /// [`MergeError::QueueFull`] if the output queue is already at its bound
237    /// — in both cases nothing from this call is buffered.
238    pub fn feed(&mut self, source: SourceId, msg: Bytes, at: Timestamp) -> Result<(), MergeError> {
239        if source.0 >= self.num_sources {
240            return Err(MergeError::UnknownSource {
241                source_id: source,
242                num_sources: self.num_sources,
243            });
244        }
245        self.last_seen[source.0] = Some(at);
246
247        let forward = match &self.policy {
248            MergePolicy::FirstArrival => true,
249            MergePolicy::Failover {
250                primary, secondary, ..
251            } => {
252                if source.0 == primary.0 {
253                    // Switch-back rule: primary reclaims active status the
254                    // instant it produces a message.
255                    self.active = primary.0;
256                    true
257                } else if source.0 == secondary.0 {
258                    self.active == secondary.0
259                } else {
260                    // Neither named source: Failover only defines behaviour
261                    // for its two named sources, so a third source's traffic
262                    // is silently uninvolved rather than an error.
263                    false
264                }
265            }
266        };
267
268        if forward {
269            if self.queue.len() >= self.max_queued {
270                return Err(MergeError::QueueFull {
271                    max_queued: self.max_queued,
272                });
273            }
274            self.queue.push_back((msg, at));
275        }
276        Ok(())
277    }
278
279    /// Pull the next merged message, in the order it was forwarded by
280    /// [`ByteMerge::feed`].
281    pub fn poll(&mut self) -> Option<(Bytes, Timestamp)> {
282        self.queue.pop_front()
283    }
284
285    /// When [`ByteMerge::on_deadline`] should next be called to check a
286    /// [`MergePolicy::Failover`] silence timeout.
287    ///
288    /// `None` under [`MergePolicy::FirstArrival`] (arrival-driven, no clock
289    /// needed) and under `Failover` before `primary` has produced its first
290    /// message (see that variant's docs).
291    pub fn next_deadline(&self) -> Option<Timestamp> {
292        match &self.policy {
293            MergePolicy::FirstArrival => None,
294            MergePolicy::Failover {
295                primary,
296                silence_timeout,
297                ..
298            } => self.last_seen[primary.0].map(|t| t.saturating_add(*silence_timeout)),
299        }
300    }
301
302    /// Drive time-based transitions: under [`MergePolicy::Failover`], switch
303    /// to `secondary` if `primary` has been silent for at least
304    /// `silence_timeout` as of `now`. A no-op under [`MergePolicy::FirstArrival`].
305    pub fn on_deadline(&mut self, now: Timestamp) {
306        if let MergePolicy::Failover {
307            primary,
308            secondary,
309            silence_timeout,
310        } = &self.policy
311            && let Some(last) = self.last_seen[primary.0]
312            && now.saturating_sub(last) >= *silence_timeout
313        {
314            self.active = secondary.0;
315        }
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    /// `FirstArrival` must interleave two sources exactly in the order
324    /// `feed` was called, regardless of which source each call came from.
325    #[test]
326    fn first_arrival_interleaves_two_sources_in_arrival_order() {
327        let mut merge = ByteMerge::new(MergePolicy::FirstArrival, 2, 8);
328        let a = SourceId(0);
329        let b = SourceId(1);
330
331        merge
332            .feed(a, Bytes::from_static(b"a0"), Timestamp::from_nanos(0))
333            .unwrap();
334        merge
335            .feed(b, Bytes::from_static(b"b0"), Timestamp::from_nanos(1))
336            .unwrap();
337        merge
338            .feed(a, Bytes::from_static(b"a1"), Timestamp::from_nanos(2))
339            .unwrap();
340        merge
341            .feed(b, Bytes::from_static(b"b1"), Timestamp::from_nanos(3))
342            .unwrap();
343
344        assert_eq!(
345            merge.poll(),
346            Some((Bytes::from_static(b"a0"), Timestamp::from_nanos(0)))
347        );
348        assert_eq!(
349            merge.poll(),
350            Some((Bytes::from_static(b"b0"), Timestamp::from_nanos(1)))
351        );
352        assert_eq!(
353            merge.poll(),
354            Some((Bytes::from_static(b"a1"), Timestamp::from_nanos(2)))
355        );
356        assert_eq!(
357            merge.poll(),
358            Some((Bytes::from_static(b"b1"), Timestamp::from_nanos(3)))
359        );
360        assert_eq!(merge.poll(), None);
361    }
362
363    /// `Failover` must not switch before the silence timeout, must not flap
364    /// on a single late (but still-within-timeout) primary message, must
365    /// switch once the timeout genuinely elapses, and must switch straight
366    /// back the instant primary is heard from again.
367    #[test]
368    fn failover_switches_after_timeout_not_on_single_late_message_then_switches_back() {
369        let primary = SourceId(0);
370        let secondary = SourceId(1);
371        let silence_timeout = Duration::from_millis(100);
372        let mut merge = ByteMerge::new(
373            MergePolicy::Failover {
374                primary,
375                secondary,
376                silence_timeout,
377            },
378            2,
379            8,
380        );
381
382        // t=0: primary speaks. Active is primary; forwarded.
383        merge
384            .feed(primary, Bytes::from_static(b"p0"), Timestamp::from_nanos(0))
385            .unwrap();
386        assert_eq!(
387            merge.poll(),
388            Some((Bytes::from_static(b"p0"), Timestamp::from_nanos(0)))
389        );
390
391        // While primary is active, secondary's traffic is dropped, not queued.
392        merge
393            .feed(
394                secondary,
395                Bytes::from_static(b"s-dropped"),
396                Timestamp::from_nanos(10),
397            )
398            .unwrap();
399        assert_eq!(merge.poll(), None);
400
401        // t=50ms: well within the 100ms timeout — must not switch.
402        merge.on_deadline(Timestamp::from_nanos(50_000_000));
403
404        // t=90ms: primary speaks again — "a single late message" arriving
405        // close to (but before) the timeout. This MUST reset the silence
406        // clock rather than merely being noted: if it didn't, the next
407        // on_deadline at t=150ms (60ms after t=90ms, well under the 100ms
408        // timeout) would incorrectly see 150ms since t=0 and switch.
409        merge
410            .feed(
411                primary,
412                Bytes::from_static(b"p-late"),
413                Timestamp::from_nanos(90_000_000),
414            )
415            .unwrap();
416        assert_eq!(
417            merge.poll(),
418            Some((
419                Bytes::from_static(b"p-late"),
420                Timestamp::from_nanos(90_000_000)
421            ))
422        );
423
424        // t=150ms: only 60ms since the t=90ms reset — must NOT have switched.
425        merge.on_deadline(Timestamp::from_nanos(150_000_000));
426        merge
427            .feed(
428                secondary,
429                Bytes::from_static(b"s-still-dropped"),
430                Timestamp::from_nanos(150_000_000),
431            )
432            .unwrap();
433        assert_eq!(
434            merge.poll(),
435            None,
436            "must not have flapped to secondary on a single late primary message"
437        );
438
439        // t=200ms: 110ms since t=90ms — genuinely silent past the timeout.
440        merge.on_deadline(Timestamp::from_nanos(200_000_000));
441        merge
442            .feed(
443                secondary,
444                Bytes::from_static(b"s0"),
445                Timestamp::from_nanos(200_000_000),
446            )
447            .unwrap();
448        assert_eq!(
449            merge.poll(),
450            Some((
451                Bytes::from_static(b"s0"),
452                Timestamp::from_nanos(200_000_000)
453            )),
454            "must have switched to secondary once genuinely silent past the timeout"
455        );
456
457        // Primary returns: reclaims active status immediately (switch-back
458        // rule), and secondary is dropped again from this point on.
459        merge
460            .feed(
461                primary,
462                Bytes::from_static(b"p-back"),
463                Timestamp::from_nanos(210_000_000),
464            )
465            .unwrap();
466        assert_eq!(
467            merge.poll(),
468            Some((
469                Bytes::from_static(b"p-back"),
470                Timestamp::from_nanos(210_000_000)
471            ))
472        );
473        merge
474            .feed(
475                secondary,
476                Bytes::from_static(b"s-dropped-again"),
477                Timestamp::from_nanos(220_000_000),
478            )
479            .unwrap();
480        assert_eq!(merge.poll(), None);
481    }
482
483    /// Per-source state must be bounded: flooding one source far beyond the
484    /// queue cap must not grow `num_sources()`, and the output queue itself
485    /// must stay capped, rejecting the overflow outright.
486    #[test]
487    fn per_source_state_and_output_queue_are_bounded_under_flood() {
488        let max_queued = 4;
489        let mut merge = ByteMerge::new(MergePolicy::FirstArrival, 2, max_queued);
490
491        let mut full_errors = 0usize;
492        for i in 0..10_000u64 {
493            match merge.feed(
494                SourceId(0),
495                Bytes::from_static(b"x"),
496                Timestamp::from_nanos(i),
497            ) {
498                Ok(()) => {}
499                Err(MergeError::QueueFull { max_queued: cap }) => {
500                    assert_eq!(cap, max_queued);
501                    full_errors += 1;
502                }
503                Err(other) => panic!("unexpected error: {other:?}"),
504            }
505            assert!(
506                merge.len() <= max_queued,
507                "queue exceeded its bound mid-flood"
508            );
509        }
510
511        // Fixed per-source state never grew from the flood.
512        assert_eq!(merge.num_sources(), 2);
513        // The queue is exactly full (never more), and the flood was mostly
514        // rejected once it was.
515        assert_eq!(merge.len(), max_queued);
516        assert!(full_errors > 0, "flood should have hit the queue bound");
517
518        let mut drained = 0usize;
519        while merge.poll().is_some() {
520            drained += 1;
521        }
522        assert_eq!(drained, max_queued);
523    }
524
525    #[test]
526    fn unknown_source_is_rejected() {
527        let mut merge = ByteMerge::new(MergePolicy::FirstArrival, 2, 4);
528        let err = merge
529            .feed(SourceId(5), Bytes::from_static(b"x"), Timestamp::ZERO)
530            .unwrap_err();
531        assert_eq!(
532            err,
533            MergeError::UnknownSource {
534                source_id: SourceId(5),
535                num_sources: 2,
536            }
537        );
538        assert!(merge.is_empty());
539    }
540
541    #[test]
542    #[should_panic(expected = "num_sources must be > 0")]
543    fn zero_sources_panics() {
544        let _ = ByteMerge::new(MergePolicy::FirstArrival, 0, 4);
545    }
546
547    #[test]
548    #[should_panic(expected = "max_queued must be > 0")]
549    fn zero_max_queued_panics() {
550        let _ = ByteMerge::new(MergePolicy::FirstArrival, 2, 0);
551    }
552
553    #[test]
554    #[should_panic(expected = "within 0..num_sources")]
555    fn failover_out_of_range_source_panics() {
556        let _ = ByteMerge::new(
557            MergePolicy::Failover {
558                primary: SourceId(0),
559                secondary: SourceId(9),
560                silence_timeout: Duration::from_millis(1),
561            },
562            2,
563            4,
564        );
565    }
566}