Skip to main content

cu29_logstream/
pacing.rs

1//! Bounded autonomous sender scheduling. All methods run off the real-time path.
2
3use crate::rlc::RepairSchedule;
4use crate::{
5    ContinuousEncoder, CuStreamTx, CuStreamTxError, Error, FiniteObjectEncoder,
6    LogStreamSenderConfig, RecordKind, RecoveryPoint, Result, decode_record, encode_record,
7    encode_recovery_point,
8};
9use alloc::{boxed::Box, vec, vec::Vec};
10use cu29_clock::{CuDuration, CuTime};
11
12/// Autonomous repetition period, measured by the sender's local RobotClock.
13/// Repetition is coalesced while the previous transmission is still pending.
14pub const RECOVERY_REPEAT_INTERVAL: CuDuration = CuDuration(250_000_000);
15const MAX_SENDS_PER_POLL: usize = 64;
16const PENDING_BOUNDARIES: usize = 4;
17const PENDING_KEYFRAMES: usize = 2;
18pub(crate) const STRUCTURED_RECORD_BYTES: usize = 4096;
19
20/// Shared destination budget. Counts complete Copper packets, excluding carrier overhead.
21#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22pub struct PacingConfig {
23    pub bitrate_bps: u64,
24    pub burst_packets: u32,
25    pub max_latency: CuDuration,
26    /// Storage budget for sender buffers and the continuous encoder. Finite-object
27    /// codec scratch allocations and thread stacks are additional, bounded by object size.
28    pub memory_budget_bytes: usize,
29}
30
31/// Counters distinguish queue shedding from carrier backpressure.
32#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
33pub struct SenderStats {
34    pub packets_sent: u64,
35    pub bytes_sent: u64,
36    pub queue_drops: u64,
37    pub expired_packets: u64,
38    pub transport_drops: u64,
39    pub recovery_rounds: u64,
40    pub recovery_superseded: u64,
41    pub shutdown_drops: u64,
42    pub queue_peak: usize,
43}
44
45/// Fixed-capacity packet storage; filling and replacing it never grows its allocation.
46struct Packets {
47    bytes: Vec<u8>,
48    lengths: Vec<usize>,
49    times: Vec<CuTime>,
50    ids: Vec<u64>,
51    mtu: usize,
52    head: usize,
53    len: usize,
54}
55
56impl Packets {
57    fn new(capacity: usize, mtu: usize) -> Self {
58        Self {
59            bytes: vec![0; capacity * mtu],
60            lengths: vec![0; capacity],
61            times: vec![CuTime::default(); capacity],
62            ids: vec![0; capacity],
63            mtu,
64            head: 0,
65            len: 0,
66        }
67    }
68
69    fn clear(&mut self) {
70        self.head = 0;
71        self.len = 0;
72    }
73
74    fn push(&mut self, bytes: &[u8], now: CuTime, id: u64) -> bool {
75        if self.len == self.lengths.len() || bytes.len() > self.mtu {
76            return false;
77        }
78        let slot = (self.head + self.len) % self.lengths.len();
79        self.bytes[slot * self.mtu..slot * self.mtu + bytes.len()].copy_from_slice(bytes);
80        self.lengths[slot] = bytes.len();
81        self.times[slot] = now;
82        self.ids[slot] = id;
83        self.len += 1;
84        true
85    }
86
87    fn get(&self, index: usize) -> &[u8] {
88        let slot = (self.head + index) % self.lengths.len();
89        &self.bytes[slot * self.mtu..slot * self.mtu + self.lengths[slot]]
90    }
91
92    fn pop(&mut self) {
93        self.head = (self.head + 1) % self.lengths.len();
94        self.len -= 1;
95    }
96}
97
98struct PendingPackets {
99    id: Option<u64>,
100    packets: Packets,
101}
102
103struct RecoveryPackets {
104    control: Packets,
105    boundary: Packets,
106}
107
108/// A destination's single packet scheduler and retained recovery state.
109///
110/// `now` always belongs to the same local RobotClock. The caller owns transport
111/// and wakeups; this core neither reads a global clock nor sleeps. Runtime objects
112/// are never retained here. Optional feedback can later request the same retained
113/// jobs through `request_recovery`, without bypassing this scheduler's budget.
114pub struct SenderCore {
115    config: LogStreamSenderConfig,
116    continuous: Box<ContinuousEncoder<1128, 64>>,
117    finite: FiniteObjectEncoder,
118    structured_encoder: FiniteObjectEncoder,
119    repairs: RepairSchedule,
120    scratch: Vec<u8>,
121    data: Packets,
122    structured: Packets,
123    manifest: Packets,
124    latest: RecoveryPackets,
125    pending_cl: Vec<PendingPackets>,
126    pending_kf: Vec<PendingPackets>,
127    latest_id: Option<u64>,
128    manifest_cursor: usize,
129    recovery_cursor: usize,
130    next_repeat: CuTime,
131    last_now: CuTime,
132    credit: u128,
133    capacity: u128,
134    // Byte deficit fairness: replay, recovery, structured logs at 3:1:1.
135    deficit: [usize; 3],
136    lane: usize,
137    stopping: bool,
138    transport_pending: bool,
139    stats: SenderStats,
140}
141
142impl SenderCore {
143    /// Changes only future repair scheduling; retained packets and budgets are untouched.
144    pub fn set_repair_interval(&mut self, every: u16) -> Result<()> {
145        if every == 0 {
146            return Err(Error::InvalidConfig("repair interval must be nonzero"));
147        }
148        self.repairs.set_interval(usize::from(every));
149        Ok(())
150    }
151
152    /// Validate all storage arithmetic before allocating. `reserved_bytes` covers
153    /// the driver's owned-record pool, which shares the destination budget.
154    pub fn new(config: LogStreamSenderConfig, now: CuTime, reserved_bytes: usize) -> Result<Self> {
155        config.validate()?;
156        let policy = config.pacing;
157        if policy.bitrate_bps == 0
158            || policy.burst_packets == 0
159            || policy.max_latency.as_nanos() == 0
160        {
161            return Err(Error::InvalidConfig("pacing bounds must be nonzero"));
162        }
163        let mtu = crate::PACKET_HEADER_LEN + config.continuous.fec.symbol_size();
164        let object_bytes = usize::try_from(config.recovery.finite.max_object_bytes)
165            .map_err(|_| Error::InvalidConfig("object limit exceeds usize"))?;
166        let symbol = usize::from(config.recovery.finite.symbol_size);
167        if symbol != config.continuous.fec.symbol_size()
168            || symbol <= crate::rlc::FRAGMENT_HEADER_LEN
169            || config.continuous.repair_every_source_symbols == 0
170            || object_bytes < 256
171        {
172            return Err(Error::InvalidConfig(
173                "scheduled lanes require matching valid symbols and object bounds",
174            ));
175        }
176        // One source block keeps finite encoding and its retained packet bound explicit.
177        if object_bytes.div_ceil(symbol) > 56_403
178            || raptorq::ObjectTransmissionInformation::with_defaults(
179                object_bytes as u64,
180                symbol as u16,
181            )
182            .source_blocks()
183                != 1
184        {
185            return Err(Error::InvalidConfig(
186                "scheduled objects must fit one RaptorQ source block",
187            ));
188        }
189        let repairs_per_object = config.recovery.finite.repair_symbols_per_block as usize;
190        let finite_capacity = |bytes: usize| bytes.div_ceil(symbol).checked_add(repairs_per_object);
191        let manifest_capacity = finite_capacity(config.recovery.manifest_record.len())
192            .ok_or(Error::InvalidConfig("manifest packet bound overflow"))?;
193        let control_capacity = finite_capacity(object_bytes)
194            .and_then(|v| finite_capacity(256).and_then(|a| v.checked_add(a)))
195            .ok_or(Error::InvalidConfig("recovery packet bound overflow"))?;
196        let source_capacity = config
197            .continuous
198            .max_record_bytes
199            .div_ceil(symbol - crate::rlc::FRAGMENT_HEADER_LEN);
200        let boundary_capacity = source_capacity
201            .checked_mul(2)
202            .ok_or(Error::InvalidConfig("boundary packet bound overflow"))?;
203        let packet_bytes = mtu + size_of::<usize>() + size_of::<CuTime>() + size_of::<u64>();
204        let retained_packets = control_capacity
205            .checked_mul(PENDING_KEYFRAMES + 1)
206            .and_then(|v| {
207                boundary_capacity
208                    .checked_mul(PENDING_BOUNDARIES + 1)
209                    .and_then(|b| v.checked_add(b))
210            })
211            .and_then(|v| v.checked_add(manifest_capacity))
212            .ok_or(Error::InvalidConfig("retained packet bound overflow"))?;
213        let fixed = retained_packets
214            .checked_mul(packet_bytes)
215            .and_then(|v| v.checked_add(size_of::<ContinuousEncoder<1128, 64>>()))
216            .and_then(|v| v.checked_add(size_of::<Self>()))
217            .and_then(|v| {
218                v.checked_add(
219                    (PENDING_BOUNDARIES + PENDING_KEYFRAMES) * size_of::<PendingPackets>(),
220                )
221            })
222            .and_then(|v| v.checked_add(mtu))
223            .and_then(|v| v.checked_add(config.recovery.manifest_record.len()))
224            .and_then(|v| v.checked_add(reserved_bytes))
225            .ok_or(Error::InvalidConfig("sender storage bound overflow"))?;
226        let queue_capacity = policy
227            .memory_budget_bytes
228            .checked_sub(fixed)
229            .map(|remaining| remaining / packet_bytes)
230            .filter(|capacity| *capacity >= boundary_capacity.max(1))
231            .ok_or(Error::InvalidConfig(
232                "sender buffers exceed destination memory budget",
233            ))?;
234        let structured_capacity = (queue_capacity / 8).max(
235            finite_capacity(object_bytes.min(STRUCTURED_RECORD_BYTES))
236                .ok_or(Error::InvalidConfig("structured packet bound overflow"))?,
237        );
238        let data_capacity = queue_capacity.saturating_sub(structured_capacity);
239        if data_capacity < boundary_capacity.max(1) {
240            return Err(Error::InvalidConfig(
241                "sender packet queues exceed memory budget",
242            ));
243        }
244        let structured_encoder = FiniteObjectEncoder::new(crate::FiniteObjectSenderConfig {
245            lane: crate::Lane::StructuredLog,
246            ..config.recovery.finite
247        })?;
248        let continuous = Box::new(ContinuousEncoder::new(
249            config.continuous.identity,
250            config.continuous.lane,
251            config.continuous.fec,
252            config.continuous.max_record_bytes,
253            config.continuous.initial_esi,
254        )?);
255        let mut finite = FiniteObjectEncoder::new(config.recovery.finite)?;
256        let mut manifest = Packets::new(manifest_capacity, mtu);
257        let manifest_id = decode_record(&config.recovery.manifest_record)?.object_id;
258        finite.push_record_with(&config.recovery.manifest_record, |packet| {
259            if !manifest.push(packet, now, manifest_id) {
260                return Err(Error::InvalidConfig("manifest packet capacity"));
261            }
262            Ok(())
263        })?;
264        let recovery = || RecoveryPackets {
265            control: Packets::new(control_capacity, mtu),
266            boundary: Packets::new(boundary_capacity, mtu),
267        };
268        let capacity = u128::from(policy.burst_packets) * mtu as u128 * 8 * 1_000_000_000;
269        Ok(Self {
270            repairs: RepairSchedule::new(
271                config.continuous.repair_every_source_symbols,
272                config.continuous.first_repair_key,
273                config.continuous.repair_density,
274            ),
275            config,
276            continuous,
277            finite,
278            structured_encoder,
279            scratch: vec![0; mtu],
280            data: Packets::new(data_capacity, mtu),
281            structured: Packets::new(structured_capacity, mtu),
282            manifest,
283            latest: recovery(),
284            pending_cl: (0..PENDING_BOUNDARIES)
285                .map(|_| PendingPackets {
286                    id: None,
287                    packets: Packets::new(boundary_capacity, mtu),
288                })
289                .collect(),
290            pending_kf: (0..PENDING_KEYFRAMES)
291                .map(|_| PendingPackets {
292                    id: None,
293                    packets: Packets::new(control_capacity, mtu),
294                })
295                .collect(),
296            latest_id: None,
297            manifest_cursor: 0,
298            recovery_cursor: 0,
299            next_repeat: now + RECOVERY_REPEAT_INTERVAL,
300            last_now: now,
301            credit: capacity,
302            capacity,
303            deficit: [0; 3],
304            lane: 0,
305            stopping: false,
306            transport_pending: false,
307            stats: SenderStats::default(),
308        })
309    }
310
311    pub const fn stats(&self) -> SenderStats {
312        self.stats
313    }
314
315    /// Admit an already encoded record from a bounded worker buffer. No borrow
316    /// escapes this call. CL packets shed on overflow; retained recovery replaces
317    /// only a complete matching boundary/keyframe pair.
318    pub fn accept_record(&mut self, record: &[u8], now: CuTime) -> Result<()> {
319        let decoded = decode_record(record)?;
320        match decoded.kind {
321            RecordKind::CopperList => {
322                let retain = decoded
323                    .object_id
324                    .is_multiple_of(u64::from(self.config.recovery.recovery_interval));
325                let slot = if retain {
326                    Some(Self::reserve(
327                        &mut self.pending_cl,
328                        decoded.object_id,
329                        &mut self.stats,
330                    ))
331                } else {
332                    None
333                };
334                let stats = &mut self.stats;
335                let pending = &mut self.pending_cl;
336                let data = &mut self.data;
337                self.continuous.push_record_with_repairs(
338                    record,
339                    &mut self.scratch,
340                    &mut |packet| {
341                        if let Some(slot) = slot
342                            && !pending[slot].packets.push(packet, now, decoded.object_id)
343                        {
344                            return Err(Error::InvalidConfig("boundary packet capacity"));
345                        }
346                        if !data.push(packet, now, decoded.object_id) {
347                            stats.queue_drops = stats.queue_drops.saturating_add(1);
348                        }
349                        stats.queue_peak = stats.queue_peak.max(data.len);
350                        Ok(())
351                    },
352                    &mut self.repairs,
353                )?;
354            }
355            RecordKind::StructuredLog => {
356                // This lane never advances CopperList continuity or recovery fences.
357                self.structured_encoder.push_record_with(record, |packet| {
358                    if !self.structured.push(packet, now, decoded.object_id) {
359                        self.stats.queue_drops = self.stats.queue_drops.saturating_add(1);
360                    }
361                    self.stats.queue_peak = self
362                        .stats
363                        .queue_peak
364                        .max(self.data.len + self.structured.len);
365                    Ok(())
366                })?;
367            }
368            RecordKind::KeyFrame => {
369                if !decoded
370                    .object_id
371                    .is_multiple_of(u64::from(self.config.recovery.recovery_interval))
372                {
373                    return Ok(());
374                }
375                let manifest = decode_record(&self.config.recovery.manifest_record)?;
376                let recovery_point = encode_record(
377                    RecordKind::RecoveryPoint,
378                    decoded.object_id,
379                    &encode_recovery_point(&RecoveryPoint {
380                        manifest_object_id: manifest.object_id,
381                        manifest_record_digest: manifest.digest,
382                        copperlist_id: decoded.object_id,
383                        keyframe_object_id: decoded.object_id,
384                        keyframe_record_digest: decoded.digest,
385                    })?,
386                )?;
387                let slot = Self::reserve(&mut self.pending_kf, decoded.object_id, &mut self.stats);
388                for bytes in [record, recovery_point.as_slice()] {
389                    self.finite.push_record_with(bytes, |packet| {
390                        if !self.pending_kf[slot]
391                            .packets
392                            .push(packet, now, decoded.object_id)
393                        {
394                            return Err(Error::InvalidConfig("recovery packet capacity"));
395                        }
396                        Ok(())
397                    })?;
398                }
399            }
400            _ => {
401                return Err(Error::InvalidConfig(
402                    "sender inbox accepts CLs, keyframes and structured logs",
403                ));
404            }
405        }
406        self.promote_recovery();
407        Ok(())
408    }
409
410    fn reserve(slots: &mut [PendingPackets], id: u64, stats: &mut SenderStats) -> usize {
411        let index = slots
412            .iter()
413            .position(|s| s.id.is_none() || s.id == Some(id))
414            .unwrap_or_else(|| {
415                slots
416                    .iter()
417                    .enumerate()
418                    .min_by_key(|(_, s)| s.id)
419                    .unwrap()
420                    .0
421            });
422        if slots[index].id.is_some() {
423            stats.recovery_superseded += 1;
424        }
425        slots[index].id = Some(id);
426        slots[index].packets.clear();
427        index
428    }
429
430    fn promote_recovery(&mut self) {
431        // Finish an in-flight bundle before replacing it; frequent captures must
432        // not continually restart a large transfer on a slow link.
433        if self.recovery_cursor < self.recovery_len() {
434            return;
435        }
436        let pair = self
437            .pending_cl
438            .iter()
439            .enumerate()
440            .filter_map(|(cl, entry)| {
441                let id = entry.id?;
442                // A recovery point must not make the receiver skip older source packets
443                // still waiting in our own queue. Expiry releases this fence too.
444                if self.data.len > 0 && self.data.ids[self.data.head] < id {
445                    return None;
446                }
447                if self.latest_id.is_some_and(|latest| id <= latest) {
448                    return None;
449                }
450                self.pending_kf
451                    .iter()
452                    .position(|kf| kf.id == Some(id))
453                    .map(|kf| (id, cl, kf))
454            })
455            .min_by_key(|(id, _, _)| *id);
456        if let Some((id, cl, kf)) = pair {
457            core::mem::swap(&mut self.latest.boundary, &mut self.pending_cl[cl].packets);
458            core::mem::swap(&mut self.latest.control, &mut self.pending_kf[kf].packets);
459            self.pending_cl[cl].id = None;
460            self.pending_kf[kf].id = None;
461            self.latest_id = Some(id);
462            self.recovery_cursor = 0;
463            self.request_recovery();
464        }
465    }
466
467    /// Coalesced request for retained bootstrap data. Future advisory feedback
468    /// uses this same operation; it grants neither extra bandwidth nor retention.
469    pub fn request_recovery(&mut self) {
470        if self.stopping || self.control_packet().is_some() {
471            return;
472        }
473        if self.manifest_cursor >= self.manifest.len {
474            self.manifest_cursor = 0;
475        }
476        if self.recovery_cursor >= self.recovery_len() {
477            self.recovery_cursor = 0;
478        }
479    }
480
481    fn recovery_len(&self) -> usize {
482        self.latest.control.len + self.latest.boundary.len
483    }
484
485    fn control_packet(&self) -> Option<&[u8]> {
486        if self.manifest_cursor < self.manifest.len {
487            return Some(self.manifest.get(self.manifest_cursor));
488        }
489        if self.recovery_cursor < self.latest.control.len {
490            return Some(self.latest.control.get(self.recovery_cursor));
491        }
492        let boundary = self.recovery_cursor - self.latest.control.len;
493        (boundary < self.latest.boundary.len).then(|| self.latest.boundary.get(boundary))
494    }
495
496    /// Disable periodic work. The driver enforces the finite shutdown deadline.
497    pub fn begin_shutdown(&mut self) {
498        self.stopping = true;
499    }
500
501    /// Account for work abandoned at the driver's finite shutdown deadline.
502    pub fn discard_pending(&mut self) {
503        self.stats.shutdown_drops += (self.data.len
504            + self.structured.len
505            + self.manifest.len.saturating_sub(self.manifest_cursor)
506            + self.recovery_len().saturating_sub(self.recovery_cursor))
507            as u64;
508        self.data.clear();
509        self.structured.clear();
510        self.manifest_cursor = self.manifest.len;
511        self.recovery_cursor = self.recovery_len();
512    }
513
514    pub fn is_idle(&self) -> bool {
515        self.data.len == 0
516            && self.structured.len == 0
517            && self.control_packet().is_none()
518            && !self.transport_pending
519    }
520
521    /// Send a bounded amount of eligible work and return the next local deadline.
522    /// Carrier WouldBlock consumes the attempt's budget and drops the packet.
523    pub fn poll<T: CuStreamTx>(
524        &mut self,
525        now: CuTime,
526        transport: &mut T,
527    ) -> Result<Option<CuTime>> {
528        let next = self.poll_packets(now, transport)?;
529        self.transport_pending = transport.poll_pending().map_err(|error| match error {
530            CuStreamTxError::WouldBlock => {
531                Error::Transport("Transport poll must report pending instead of WouldBlock")
532            }
533            CuStreamTxError::Failed(message) => Error::Transport(message),
534        })?;
535        Ok(if self.transport_pending {
536            let retry = now + CuDuration::from_millis(1);
537            Some(next.map_or(retry, |deadline| deadline.min(retry)))
538        } else {
539            next
540        })
541    }
542
543    fn poll_packets<T: CuStreamTx>(
544        &mut self,
545        now: CuTime,
546        transport: &mut T,
547    ) -> Result<Option<CuTime>> {
548        if now < self.last_now {
549            return Err(Error::InvalidConfig("sender clock moved backwards"));
550        }
551        let elapsed = (now - self.last_now).as_nanos();
552        self.credit = self.capacity.min(
553            self.credit
554                .saturating_add(u128::from(elapsed) * u128::from(self.config.pacing.bitrate_bps)),
555        );
556        self.last_now = now;
557        self.promote_recovery();
558        if !self.stopping && now >= self.next_repeat {
559            self.request_recovery();
560            self.stats.recovery_rounds = self.stats.recovery_rounds.saturating_add(1);
561            self.next_repeat = now + RECOVERY_REPEAT_INTERVAL;
562        }
563        let mut work = 0;
564        for queue in [&mut self.data, &mut self.structured] {
565            while queue.len > 0
566                && now >= queue.times[queue.head] + self.config.pacing.max_latency
567                && work < MAX_SENDS_PER_POLL
568            {
569                queue.pop();
570                self.stats.expired_packets += 1;
571                work += 1;
572            }
573        }
574        while work < MAX_SENDS_PER_POLL {
575            let available = [
576                self.data.len > 0,
577                self.control_packet().is_some(),
578                self.structured.len > 0,
579            ];
580            if !available.iter().any(|ready| *ready) {
581                break;
582            }
583            if !available[self.lane] {
584                self.deficit[self.lane] = 0;
585                self.lane = (self.lane + 1) % 3;
586                continue;
587            }
588            let packet = match self.lane {
589                0 => self.data.get(0),
590                1 => self.control_packet().unwrap(),
591                _ => self.structured.get(0),
592            };
593            let len = packet.len();
594            if self.deficit[self.lane] < len {
595                self.deficit[self.lane] += self.scratch.len() * if self.lane == 0 { 3 } else { 1 };
596                self.lane = (self.lane + 1) % 3;
597                continue;
598            }
599            let cost = len as u128 * 8 * 1_000_000_000;
600            if self.credit < cost {
601                let wait =
602                    (cost - self.credit).div_ceil(u128::from(self.config.pacing.bitrate_bps));
603                let mut deadline = now + CuDuration(wait.min(u128::from(u64::MAX)) as u64);
604                for queue in [&self.data, &self.structured] {
605                    if queue.len > 0 {
606                        deadline =
607                            deadline.min(queue.times[queue.head] + self.config.pacing.max_latency);
608                    }
609                }
610                return Ok(Some(if self.stopping {
611                    deadline
612                } else {
613                    deadline.min(self.next_repeat)
614                }));
615            }
616            match transport.try_send(packet) {
617                Ok(()) => {
618                    self.stats.packets_sent += 1;
619                    self.stats.bytes_sent += len as u64;
620                }
621                Err(CuStreamTxError::WouldBlock) => self.stats.transport_drops += 1,
622                Err(CuStreamTxError::Failed(message)) => return Err(Error::Transport(message)),
623            }
624            self.credit -= cost;
625            self.deficit[self.lane] -= len;
626            if self.lane == 0 {
627                self.data.pop();
628            } else if self.lane == 2 {
629                self.structured.pop();
630            } else if self.manifest_cursor < self.manifest.len {
631                self.manifest_cursor += 1;
632            } else {
633                self.recovery_cursor += 1;
634            }
635            work += 1;
636            self.promote_recovery();
637        }
638        Ok(
639            if self.data.len != 0 || self.structured.len != 0 || self.control_packet().is_some() {
640                Some(now)
641            } else if self.stopping {
642                None
643            } else {
644                Some(self.next_repeat)
645            },
646        )
647    }
648}