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}