Skip to main content

subetha_cxc/
compressed_udp.rs

1//! Schema-aware structural compression wrapped around the reliable-UDP
2//! transport at the item boundary.
3//!
4//! The sender compresses each slot with a learned [`SchemaTemplate`]
5//! before it enters the FEC encoder (the TX egress-gate: with per-block
6//! shard sizing the smaller item becomes a smaller datagram). The receiver
7//! decompresses after FEC reassembly, before delivery (the RX
8//! read-modifier).
9//!
10//! Each transport item is tagged: a one-byte tag distinguishes a template
11//! descriptor from a compressed slot, so the template is negotiated in-band
12//! and can be **re-sent mid-stream** when the slot schema drifts. The
13//! template descriptor is far larger than the 16-byte wire heartbeat, so it
14//! rides as its own FEC/ARQ-reliable, in-order control item rather than on
15//! the heartbeat plane.
16//!
17//! With re-learning enabled ([`with_relearn`](CompressedSender::with_relearn)),
18//! the sender tracks the escape rate (slots that violate the template ship
19//! in full) and, when it crosses a threshold over a window, re-learns the
20//! template from recent slots and ships the new one in-band - so a drifting
21//! schema is handled without a delivery gap.
22//!
23//! It is **exact**: the codec escapes any slot that violates the template,
24//! so delivery is byte-identical to the uncompressed path. This wraps, and
25//! does not modify, [`ReliableUdpSender`] / [`ReliableUdpReceiver`]; it
26//! composes with sharding (one template per shard).
27
28use std::collections::VecDeque;
29use std::io;
30use std::net::SocketAddr;
31use std::time::Duration;
32
33use crate::schema_codec::SchemaTemplate;
34use crate::udp_bridge::{ReliableUdpReceiver, ReliableUdpSender};
35
36/// Item tags. A data item is a compressed slot; a template item is a
37/// serialized [`SchemaTemplate`] that re-points the receiver's decoder.
38const TAG_DATA: u8 = 0;
39const TAG_TEMPLATE: u8 = 1;
40/// A coalesced batch: `[TAG_BATCH][u16 len][cslot][u16 len][cslot]...`,
41/// packing many compressed slots into one MTU-sized item so the datagram
42/// rate stops being the bottleneck on small slots.
43const TAG_BATCH: u8 = 2;
44
45/// Sending half: tags and compresses each slot, ships the template first,
46/// and (optionally) re-learns it mid-stream.
47pub struct CompressedSender {
48    inner: ReliableUdpSender,
49    tpl: SchemaTemplate,
50    item: Vec<u8>,
51    started: bool,
52    relearn: bool,
53    window: usize,
54    threshold_pct: u32,
55    recent: VecDeque<Vec<u8>>,
56    escapes: usize,
57    since_check: usize,
58    relearns: usize,
59    coalesce: bool,
60    batch_target: usize,
61    batch: Vec<u8>,
62    cslot: Vec<u8>,
63}
64
65impl CompressedSender {
66    /// Wrap an existing sender with a learned template.
67    pub fn new(inner: ReliableUdpSender, tpl: SchemaTemplate) -> Self {
68        let cap = tpl.width() + 16;
69        Self {
70            inner,
71            tpl,
72            item: Vec::with_capacity(cap),
73            started: false,
74            relearn: false,
75            window: 0,
76            threshold_pct: 0,
77            recent: VecDeque::new(),
78            escapes: 0,
79            since_check: 0,
80            relearns: 0,
81            coalesce: false,
82            batch_target: 0,
83            batch: Vec::new(),
84            cslot: Vec::with_capacity(cap),
85        }
86    }
87
88    /// How many times the template has been re-learned mid-stream (the
89    /// observable signal that the schema drifted and adaptation fired).
90    pub fn relearns(&self) -> usize {
91        self.relearns
92    }
93
94    /// Coalesce many compressed slots into one transport item of up to
95    /// `target` bytes before the FEC encoder, so the datagram rate stops
96    /// bounding throughput on small slots (the stream bridges do the same,
97    /// 256 slots per socket write). `target` should be near the FEC
98    /// `max_item`. A partial batch flushes on [`flush`](Self::flush) or a
99    /// re-learn. Off by default (one slot per item, lowest latency).
100    pub fn with_coalesce(mut self, target: usize) -> Self {
101        self.coalesce = true;
102        self.batch_target = target.max(8);
103        self.batch = Vec::with_capacity(self.batch_target + 8);
104        self.batch.push(TAG_BATCH);
105        self
106    }
107
108    /// Ship the accumulated batch (if any) as one item and reset it.
109    fn flush_batch(&mut self) -> io::Result<()> {
110        if self.batch.len() > 1 {
111            self.inner.send_item(&self.batch)?;
112            self.batch.clear();
113            self.batch.push(TAG_BATCH);
114        }
115        Ok(())
116    }
117
118    /// Bind a fresh sender to `peer` with `k`+`r` FEC and a `max_item`
119    /// FEC payload (must hold the tagged serialized template and a
120    /// worst-case tagged escaped slot), wrapped with `tpl`.
121    pub fn bind(
122        peer: SocketAddr,
123        k: usize,
124        r: usize,
125        max_item: usize,
126        tpl: SchemaTemplate,
127    ) -> io::Result<Self> {
128        let inner = ReliableUdpSender::bind("0.0.0.0:0", peer, k, r, max_item)?;
129        Ok(Self::new(inner, tpl))
130    }
131
132    /// Enable adaptive re-learning: keep the last `window` slots, and when
133    /// the escape rate over a window exceeds `threshold_pct`, re-learn the
134    /// template from those slots and ship the new one in-band.
135    pub fn with_relearn(mut self, window: usize, threshold_pct: u32) -> Self {
136        self.relearn = true;
137        self.window = window.max(64);
138        self.threshold_pct = threshold_pct.min(100);
139        self.recent = VecDeque::with_capacity(self.window);
140        self
141    }
142
143    fn send_template(&mut self) -> io::Result<()> {
144        self.item.clear();
145        self.item.push(TAG_TEMPLATE);
146        self.item.extend_from_slice(&self.tpl.serialize());
147        self.inner.send_item(&self.item)
148    }
149
150    fn relearn_now(&mut self) -> io::Result<()> {
151        let width = self.tpl.width();
152        let sample: Vec<&[u8]> = self.recent.iter().map(|s| s.as_slice()).collect();
153        self.tpl = SchemaTemplate::learn(&sample, width);
154        self.relearns += 1;
155        self.send_template()
156    }
157
158    /// Compress `slot` and ship it (tagged). The first call ships the
159    /// template; with re-learning on, a drifting schema triggers a new
160    /// template in-band.
161    pub fn send_item(&mut self, slot: &[u8]) -> io::Result<()> {
162        if !self.started {
163            self.send_template()?;
164            self.started = true;
165        }
166        if self.relearn {
167            if self.recent.len() == self.window {
168                self.recent.pop_front();
169            }
170            self.recent.push_back(slot.to_vec());
171        }
172        self.cslot.clear();
173        self.tpl.encode(slot, &mut self.cslot);
174        let escaped = SchemaTemplate::is_escape(&self.cslot);
175
176        if self.coalesce {
177            // Flush first if this slot would overflow the batch target,
178            // then frame it as `[u16 len][cslot]` into the batch.
179            if self.batch.len() + 2 + self.cslot.len() > self.batch_target && self.batch.len() > 1 {
180                self.flush_batch()?;
181            }
182            self.batch
183                .extend_from_slice(&(self.cslot.len() as u16).to_le_bytes());
184            self.batch.extend_from_slice(&self.cslot);
185        } else {
186            self.item.clear();
187            self.item.push(TAG_DATA);
188            self.item.extend_from_slice(&self.cslot);
189            self.inner.send_item(&self.item)?;
190        }
191
192        if self.relearn {
193            if escaped {
194                self.escapes += 1;
195            }
196            self.since_check += 1;
197            if self.since_check >= self.window {
198                if self.escapes * 100 > self.threshold_pct as usize * self.window {
199                    // Flush old-template slots before the new template so
200                    // ordering holds at the receiver.
201                    self.flush_batch()?;
202                    self.relearn_now()?;
203                }
204                self.escapes = 0;
205                self.since_check = 0;
206            }
207        }
208        Ok(())
209    }
210
211    /// True while the FEC flow window is full (delegates to the inner
212    /// sender). Pair with [`pump_feedback`](Self::pump_feedback).
213    pub fn flow_blocked(&self) -> bool {
214        self.inner.flow_blocked()
215    }
216
217    /// Drain inbound feedback (acks / NAKs), advancing the flow window.
218    pub fn pump_feedback(&mut self) -> io::Result<()> {
219        self.inner.pump_feedback()
220    }
221
222    /// Flush the pending coalesce batch, then the final partial FEC block.
223    pub fn flush(&mut self) -> io::Result<()> {
224        self.flush_batch()?;
225        self.inner.flush()
226    }
227
228    /// Block until every shipped block is acknowledged or `timeout`.
229    pub fn drain_until_acked(&mut self, timeout: Duration) -> io::Result<bool> {
230        self.inner.drain_until_acked(timeout)
231    }
232}
233
234/// Receiving half: applies tagged template updates, decompresses tagged
235/// data items into full slots.
236pub struct CompressedReceiver {
237    inner: ReliableUdpReceiver,
238    tpl: Option<SchemaTemplate>,
239}
240
241impl CompressedReceiver {
242    /// Wrap an existing receiver.
243    pub fn new(inner: ReliableUdpReceiver) -> Self {
244        Self { inner, tpl: None }
245    }
246
247    /// Bind a fresh receiver on `local`, wrapped.
248    pub fn bind(local: SocketAddr) -> io::Result<Self> {
249        Ok(Self::new(ReliableUdpReceiver::bind(local)?))
250    }
251
252    /// Poll the transport, returning decompressed slots in stream order. A
253    /// template item updates the decoder and emits no slot; a data item is
254    /// decompressed with the current template.
255    pub fn poll(&mut self) -> io::Result<Vec<Vec<u8>>> {
256        let raw = self.inner.poll()?;
257        let mut out = Vec::with_capacity(raw.len());
258        for item in raw {
259            if item.is_empty() {
260                continue;
261            }
262            let (tag, payload) = (item[0], &item[1..]);
263            if tag == TAG_TEMPLATE {
264                // A malformed descriptor leaves the current template in
265                // place rather than dropping into an un-templated state.
266                if let Some(t) = SchemaTemplate::deserialize(payload) {
267                    self.tpl = Some(t);
268                }
269            } else if tag == TAG_BATCH {
270                // `[u16 len][cslot]...` - split and decode each slot.
271                if let Some(t) = &self.tpl {
272                    let mut off = 0usize;
273                    while off + 2 <= payload.len() {
274                        let len = u16::from_le_bytes([payload[off], payload[off + 1]]) as usize;
275                        off += 2;
276                        if off + len > payload.len() {
277                            break;
278                        }
279                        let mut slot = vec![0u8; t.width()];
280                        t.decode(&payload[off..off + len], &mut slot);
281                        out.push(slot);
282                        off += len;
283                    }
284                }
285            } else if let Some(t) = &self.tpl {
286                // TAG_DATA: a single compressed slot.
287                let mut slot = vec![0u8; t.width()];
288                t.decode(payload, &mut slot);
289                out.push(slot);
290            }
291        }
292        Ok(out)
293    }
294
295    /// Drive tail-ARQ feedback when idle (delegates to the inner
296    /// receiver).
297    pub fn nudge_feedback(&mut self) -> io::Result<()> {
298        self.inner.nudge_feedback()
299    }
300
301    /// Inject diagnostic loss on the inner receiver (test surface).
302    pub fn with_debug_loss(mut self, pct: u32, seed: u64) -> Self {
303        self.inner = self.inner.with_debug_loss(pct, seed);
304        self
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use crate::shared_deque_khpd::{FatLineItem, LineItem};
312    use std::net::{IpAddr, Ipv4Addr};
313    use std::thread;
314    use subetha_core::Marshal;
315
316    struct Rng(u64);
317    impl Rng {
318        fn new(s: u64) -> Self {
319            Self(s | 1)
320        }
321        fn next(&mut self) -> u64 {
322            let mut x = self.0;
323            x ^= x << 13;
324            x ^= x >> 7;
325            x ^= x << 17;
326            self.0 = x;
327            x
328        }
329        fn byte(&mut self) -> u8 {
330            (self.next() >> 24) as u8
331        }
332        fn below(&mut self, n: u64) -> u64 {
333            self.next() % n
334        }
335    }
336
337    /// `phase` shifts which bytes are constant, so a template learned in
338    /// phase 0 escapes heavily in phase 1 until the sender re-learns.
339    fn slots(n: usize, seed: u64, phase: u8) -> Vec<[u8; 64]> {
340        let mut rng = Rng::new(seed);
341        let mut out = Vec::with_capacity(n);
342        let mut id = 0u32;
343        for _ in 0..n {
344            let cnt = 1 + rng.below(3) as usize;
345            let mut items = Vec::with_capacity(cnt);
346            for _ in 0..cnt {
347                let mut b = [0u8; 16];
348                b[0] = rng.below(16) as u8;
349                b[1] = phase; // phase byte: constant within a phase
350                b[4..8].copy_from_slice(&id.to_le_bytes());
351                id = id.wrapping_add(1);
352                for x in b.iter_mut().skip(8) {
353                    *x = rng.byte();
354                }
355                items.push(LineItem::new(&b).unwrap());
356            }
357            let fat = FatLineItem::from_items(&items).unwrap();
358            let mut s = [0u8; 64];
359            fat.marshal(&mut s);
360            out.push(s);
361        }
362        out
363    }
364
365    fn run_at_loss(loss: u32, port: u16) {
366        let total = 4000usize;
367        let data = slots(total, 0x99, 0);
368        let sample: Vec<&[u8]> = data.iter().step_by(7).map(|s| s.as_slice()).collect();
369        let tpl = SchemaTemplate::learn(&sample, 64);
370        let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port);
371
372        let mut recv = CompressedReceiver::bind(addr).unwrap();
373        if loss > 0 {
374            recv = recv.with_debug_loss(loss, 0x1234);
375        }
376        let expect = data.clone();
377        let rx = thread::spawn(move || -> bool {
378            let mut got: Vec<Vec<u8>> = Vec::with_capacity(total);
379            let start = std::time::Instant::now();
380            while got.len() < total {
381                if start.elapsed() > Duration::from_secs(60) {
382                    return false;
383                }
384                for s in recv.poll().unwrap_or_default() {
385                    got.push(s);
386                }
387            }
388            for _ in 0..50 {
389                recv.nudge_feedback().ok();
390                thread::sleep(Duration::from_millis(2));
391            }
392            got.len() == total && got.iter().zip(expect.iter()).all(|(a, b)| a.as_slice() == &b[..])
393        });
394
395        thread::sleep(Duration::from_millis(150));
396        let mut send = CompressedSender::bind(addr, 8, 2, 256, tpl).unwrap();
397        for s in &data {
398            while send.flow_blocked() {
399                send.pump_feedback().ok();
400                if send.flow_blocked() {
401                    thread::sleep(Duration::from_micros(50));
402                }
403            }
404            send.send_item(s).unwrap();
405        }
406        send.flush().unwrap();
407        let acked = send.drain_until_acked(Duration::from_secs(60)).unwrap();
408        let ok = rx.join().unwrap();
409        assert!(ok, "byte-exact delivery failed at {loss}% loss");
410        assert!(acked, "not fully acked at {loss}% loss");
411    }
412
413    #[test]
414    fn compressed_exact_clean() {
415        run_at_loss(0, 25410);
416    }
417
418    #[test]
419    fn compressed_exact_15pct_loss() {
420        run_at_loss(15, 25411);
421    }
422
423    #[test]
424    fn compressed_exact_30pct_loss() {
425        run_at_loss(30, 25412);
426    }
427
428    /// A mid-stream schema drift: the second half of the stream uses a
429    /// different phase byte, so the phase-0 template escapes until the
430    /// sender re-learns. Delivery stays byte-exact across the drift.
431    #[test]
432    fn relearn_keeps_exact_across_schema_drift() {
433        let half = 3000usize;
434        let mut data = slots(half, 0x21, 0);
435        data.extend(slots(half, 0x22, 7)); // drift: different phase byte
436        let total = data.len();
437        let sample: Vec<&[u8]> = data[..half].iter().step_by(5).map(|s| s.as_slice()).collect();
438        let tpl = SchemaTemplate::learn(&sample, 64);
439        let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 25420);
440
441        let mut recv = CompressedReceiver::bind(addr).unwrap();
442        let expect = data.clone();
443        let rx = thread::spawn(move || -> bool {
444            let mut got: Vec<Vec<u8>> = Vec::with_capacity(total);
445            let start = std::time::Instant::now();
446            while got.len() < total {
447                if start.elapsed() > Duration::from_secs(60) {
448                    return false;
449                }
450                for s in recv.poll().unwrap_or_default() {
451                    got.push(s);
452                }
453            }
454            let ok = got.len() == total
455                && got.iter().zip(expect.iter()).all(|(a, b)| a.as_slice() == &b[..]);
456            // Keep nudging tail-ARQ feedback so the sender's final blocks ack.
457            for _ in 0..50 {
458                recv.nudge_feedback().ok();
459                thread::sleep(Duration::from_millis(2));
460            }
461            ok
462        });
463
464        thread::sleep(Duration::from_millis(150));
465        // Small window + low threshold so the drift triggers a re-learn.
466        let mut send = CompressedSender::bind(addr, 8, 2, 256, tpl)
467            .unwrap()
468            .with_relearn(256, 20);
469        for s in &data {
470            while send.flow_blocked() {
471                send.pump_feedback().ok();
472                if send.flow_blocked() {
473                    thread::sleep(Duration::from_micros(50));
474                }
475            }
476            send.send_item(s).unwrap();
477        }
478        send.flush().unwrap();
479        let acked = send.drain_until_acked(Duration::from_secs(60)).unwrap();
480        assert!(rx.join().unwrap(), "byte-exact across schema drift failed");
481        assert!(acked, "not fully acked");
482    }
483
484    /// Coalescing packs many compressed slots per item; delivery stays
485    /// byte-exact including FEC recovery on the batched items under loss.
486    #[test]
487    fn coalesce_exact_with_loss() {
488        let total = 6000usize;
489        let data = slots(total, 0x77, 0);
490        let sample: Vec<&[u8]> = data.iter().step_by(7).map(|s| s.as_slice()).collect();
491        let tpl = SchemaTemplate::learn(&sample, 64);
492        let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 25430);
493
494        let mut recv = CompressedReceiver::bind(addr).unwrap().with_debug_loss(15, 0x55);
495        let expect = data.clone();
496        let rx = thread::spawn(move || -> bool {
497            let mut got: Vec<Vec<u8>> = Vec::with_capacity(total);
498            let start = std::time::Instant::now();
499            while got.len() < total {
500                if start.elapsed() > Duration::from_secs(60) {
501                    return false;
502                }
503                for s in recv.poll().unwrap_or_default() {
504                    got.push(s);
505                }
506            }
507            let ok = got.len() == total
508                && got.iter().zip(expect.iter()).all(|(a, b)| a.as_slice() == &b[..]);
509            for _ in 0..50 {
510                recv.nudge_feedback().ok();
511                thread::sleep(Duration::from_millis(2));
512            }
513            ok
514        });
515
516        thread::sleep(Duration::from_millis(150));
517        let mut send = CompressedSender::bind(addr, 8, 2, 1400, tpl)
518            .unwrap()
519            .with_coalesce(1200);
520        for s in &data {
521            while send.flow_blocked() {
522                send.pump_feedback().ok();
523                if send.flow_blocked() {
524                    thread::sleep(Duration::from_micros(50));
525                }
526            }
527            send.send_item(s).unwrap();
528        }
529        send.flush().unwrap();
530        let acked = send.drain_until_acked(Duration::from_secs(60)).unwrap();
531        assert!(rx.join().unwrap(), "coalesced byte-exact failed");
532        assert!(acked, "coalesced not fully acked");
533    }
534}