Skip to main content

rtc_interceptor/gcc/
arrival_group.rs

1//! Grouping acknowledgements into bursts, and measuring the delay gradient between them.
2
3use crate::rtpfb::acknowledgement::PacketReport;
4use std::time::{Duration, Instant};
5
6/// Packets sent within this of each other are one burst.
7///
8/// A sender does not emit packets one at a time — a video frame is a burst — and the delay signal
9/// lives *between* bursts, not inside them. Grouping too finely turns the pacer's own release
10/// spacing into a delay measurement.
11pub const DEFAULT_BURST_INTERVAL: Duration = Duration::from_millis(5);
12
13/// A run of packets that departed together, and when they turned up.
14#[derive(Debug, Clone, Copy, PartialEq)]
15pub struct ArrivalGroup {
16    /// When the first packet of the group left.
17    pub first_departure: Instant,
18    /// When the last packet of the group left.
19    pub departure: Instant,
20    /// When the last packet of the group arrived, on the receiver's clock.
21    pub arrival: Duration,
22    /// How many packets the group holds.
23    pub packets: usize,
24    /// Total wire size, in bytes.
25    pub size: usize,
26}
27
28/// The delay gradient between two consecutive groups.
29///
30/// This is the measurement everything downstream is built on: if the path is not queueing, packets
31/// spread apart on arrival exactly as much as they were spread apart on departure, and this is
32/// zero. A growing queue makes arrivals spread *more* than departures, and it goes positive.
33#[derive(Debug, Clone, Copy, PartialEq)]
34pub struct InterGroupDelay {
35    /// Arrival spread minus departure spread, in milliseconds. Positive means the queue is growing.
36    pub delta_ms: f64,
37    /// When the later group's last packet departed — the instant this measurement belongs to.
38    pub at: Instant,
39    /// Bytes in the later group, for the rate calculation downstream.
40    pub size: usize,
41}
42
43/// Collects acknowledgements into bursts and emits the gradient between consecutive bursts.
44///
45/// # Difference from upstream
46///
47/// Upstream's accumulator emits a group only when the *next* group begins, so the final group is
48/// never emitted (`arrival_group_accumulator.go:26-67`). Live that is invisible; in a test that
49/// ends, the last measurement silently disappears. Here [`flush`](Self::flush) exists so a test can
50/// close the stream, and the live path is unchanged.
51#[derive(Debug, Clone)]
52pub struct ArrivalGroupAccumulator {
53    burst_interval: Duration,
54    current: Option<ArrivalGroup>,
55    previous: Option<ArrivalGroup>,
56}
57
58impl Default for ArrivalGroupAccumulator {
59    fn default() -> Self {
60        Self::new(DEFAULT_BURST_INTERVAL)
61    }
62}
63
64impl ArrivalGroupAccumulator {
65    /// An accumulator grouping packets sent within `burst_interval` of each other.
66    pub fn new(burst_interval: Duration) -> Self {
67        Self {
68            burst_interval,
69            current: None,
70            previous: None,
71        }
72    }
73
74    /// Feed one report. Returns a gradient when this report started a new group.
75    ///
76    /// Reports that did not arrive carry no timing and are skipped — loss is the loss controller's
77    /// signal, not the delay controller's.
78    pub fn accumulate(&mut self, report: &PacketReport) -> Option<InterGroupDelay> {
79        let arrival = report.arrival?;
80        if !report.arrived {
81            return None;
82        }
83
84        let Some(current) = self.current.as_mut() else {
85            self.current = Some(ArrivalGroup {
86                first_departure: report.departure,
87                departure: report.departure,
88                arrival,
89                packets: 1,
90                size: report.size,
91            });
92            return None;
93        };
94
95        // Still the same burst: extend it.
96        if report
97            .departure
98            .saturating_duration_since(current.first_departure)
99            <= self.burst_interval
100        {
101            current.departure = current.departure.max(report.departure);
102            current.arrival = current.arrival.max(arrival);
103            current.packets += 1;
104            current.size += report.size;
105            return None;
106        }
107
108        // A new burst begins, so the one that just closed can be measured against its predecessor.
109        let closed = *current;
110        self.current = Some(ArrivalGroup {
111            first_departure: report.departure,
112            departure: report.departure,
113            arrival,
114            packets: 1,
115            size: report.size,
116        });
117
118        let measurement = self.previous.map(|previous| gradient(&previous, &closed));
119        self.previous = Some(closed);
120        measurement
121    }
122
123    /// Close the stream, emitting the gradient for the group still open.
124    ///
125    /// Upstream has no equivalent and therefore drops its last group. Live that does not matter;
126    /// for a test with a definite end it is the difference between measuring what happened and
127    /// measuring all but the last of it.
128    pub fn flush(&mut self) -> Option<InterGroupDelay> {
129        let closed = self.current.take()?;
130        let measurement = self.previous.map(|previous| gradient(&previous, &closed));
131        self.previous = Some(closed);
132        measurement
133    }
134}
135
136/// Arrival spread minus departure spread, between two consecutive groups.
137fn gradient(previous: &ArrivalGroup, current: &ArrivalGroup) -> InterGroupDelay {
138    let arrival_delta = current.arrival.as_secs_f64() - previous.arrival.as_secs_f64();
139    let departure_delta = current
140        .departure
141        .saturating_duration_since(previous.departure)
142        .as_secs_f64();
143
144    InterGroupDelay {
145        delta_ms: (arrival_delta - departure_delta) * 1_000.0,
146        at: current.departure,
147        size: current.size,
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use rtcp::transport_feedbacks::cc_feedback_report::Ecn;
155
156    fn report(departure: Instant, arrival_ms: u64, size: usize) -> PacketReport {
157        PacketReport {
158            ssrc: 1,
159            id: 0,
160            rtp_sequence_number: 0,
161            is_twcc: true,
162            twcc_sequence_number: 0,
163            size,
164            arrived: true,
165            departure,
166            arrival: Some(Duration::from_millis(arrival_ms)),
167            ecn: Ecn::default(),
168        }
169    }
170
171    /// Packets sent inside the burst interval are one group, whatever their count.
172    #[test]
173    fn packets_sent_together_form_one_group() {
174        let epoch = Instant::now();
175        let mut accumulator = ArrivalGroupAccumulator::default();
176
177        for offset in [0, 1, 2, 3, 4] {
178            assert_eq!(
179                None,
180                accumulator.accumulate(&report(epoch + Duration::from_millis(offset), 100, 1200)),
181                "nothing is emitted until a group closes"
182            );
183        }
184
185        // 20 ms later is a new burst, which closes the first — but there is no predecessor yet.
186        assert_eq!(
187            None,
188            accumulator.accumulate(&report(epoch + Duration::from_millis(20), 120, 1200))
189        );
190        let group = accumulator
191            .flush()
192            .expect("the second group closes against the first");
193        assert_eq!(1200, group.size, "the second group holds one packet");
194    }
195
196    /// A path that is not queueing: arrivals spread exactly as much as departures, so the gradient
197    /// is zero. This is the baseline everything else is measured against.
198    #[test]
199    fn a_path_that_does_not_queue_has_a_zero_gradient() {
200        let epoch = Instant::now();
201        let mut accumulator = ArrivalGroupAccumulator::default();
202        let mut gradients = Vec::new();
203
204        // One packet every 20 ms, arriving 20 ms apart.
205        for burst in 0..5u64 {
206            let departure = epoch + Duration::from_millis(burst * 20);
207            if let Some(delay) = accumulate_and_flush(&mut accumulator, departure, 100 + burst * 20)
208            {
209                gradients.push(delay.delta_ms);
210            }
211        }
212
213        assert!(
214            gradients.iter().all(|delta| delta.abs() < 1e-6),
215            "a non-queueing path must measure zero delay gradient: {gradients:?}"
216        );
217    }
218
219    /// A queue building: arrivals spread *more* than departures, so the gradient goes positive.
220    #[test]
221    fn a_growing_queue_has_a_positive_gradient() {
222        let epoch = Instant::now();
223        let mut accumulator = ArrivalGroupAccumulator::default();
224        let mut gradients = Vec::new();
225
226        // Sent 20 ms apart, arriving 30 ms apart: 10 ms of queue per group.
227        for burst in 0..5u64 {
228            let departure = epoch + Duration::from_millis(burst * 20);
229            if let Some(delay) = accumulate_and_flush(&mut accumulator, departure, 100 + burst * 30)
230            {
231                gradients.push(delay.delta_ms);
232            }
233        }
234
235        assert!(!gradients.is_empty(), "no gradients were produced");
236        assert!(
237            gradients.iter().all(|delta| (*delta - 10.0).abs() < 1e-6),
238            "each group should measure 10 ms of added delay: {gradients:?}"
239        );
240    }
241
242    /// A queue draining: arrivals spread *less* than departures, so the gradient goes negative.
243    #[test]
244    fn a_draining_queue_has_a_negative_gradient() {
245        let epoch = Instant::now();
246        let mut accumulator = ArrivalGroupAccumulator::default();
247        let mut gradients = Vec::new();
248
249        for burst in 0..5u64 {
250            let departure = epoch + Duration::from_millis(burst * 20);
251            if let Some(delay) = accumulate_and_flush(&mut accumulator, departure, 200 + burst * 15)
252            {
253                gradients.push(delay.delta_ms);
254            }
255        }
256
257        assert!(
258            gradients.iter().all(|delta| *delta < 0.0),
259            "a draining queue must measure negative: {gradients:?}"
260        );
261    }
262
263    /// Lost packets carry no timing and must not be measured as though they arrived at zero.
264    #[test]
265    fn lost_packets_are_not_measured() {
266        let epoch = Instant::now();
267        let mut accumulator = ArrivalGroupAccumulator::default();
268
269        let mut lost = report(epoch, 0, 1200);
270        lost.arrived = false;
271        lost.arrival = None;
272
273        assert_eq!(None, accumulator.accumulate(&lost));
274        assert_eq!(
275            None,
276            accumulator.flush(),
277            "a lost packet must not open a group"
278        );
279    }
280
281    /// One packet per burst, so every call closes the previous group.
282    fn accumulate_and_flush(
283        accumulator: &mut ArrivalGroupAccumulator,
284        departure: Instant,
285        arrival_ms: u64,
286    ) -> Option<InterGroupDelay> {
287        accumulator.accumulate(&report(departure, arrival_ms, 1200))
288    }
289}