Skip to content

Commit 5c0a615

Browse files
kixelatedclaude
andcommitted
feat(moq-net): cache compressed frame payloads, decode at delivery
Compressible tracks (TrackInfo.compress) are Deflate-coded on the lite-05+ wire. The model previously inflated every frame on ingress and re-deflated it per egress connection, so a relay burned CPU decompressing and recompressing identical bytes for each downstream peer, and its in-RAM byte count tracked the decompressed size rather than what it actually stores and sends. Cache the bytes verbatim instead. The lite subscriber stores frames in the codec they arrived in, recorded on the new Frame::compression field. This is distinct from the compress egress hint: an origin marks a track compress but writes plaintext, so its cached frames stay None. Egress then: - forwards verbatim when the cached codec matches the wire codec (the common relay to modern-peer path: no inflate or deflate), and - recodes only when they differ: an origin compressing its plaintext, or a compression-incapable peer (old lite draft, IETF moq-transport, or an in-process consumer) that needs plaintext. FrameConsumer::read_all decodes against Frame::compression, so application consumers (the moq-json catalog reader, etc.) and the IETF / old-lite egress paths keep getting plaintext unchanged. read_chunk streams the stored bytes verbatim for the relay passthrough. The wire format is unchanged. The model's natural byte count now equals the compressed/wire count, which is what usage billing should meter. The old-client-decompress path undercounts egress (counts compressed, sends decompressed), an accepted tradeoff over overcounting. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 9968539 commit 5c0a615

10 files changed

Lines changed: 338 additions & 74 deletions

File tree

rs/hang/src/container/frame.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ impl Frame {
6262
let net_frame = moq_net::Frame {
6363
size,
6464
timestamp: net_timestamp,
65+
compression: moq_net::Compression::None,
6566
};
6667
let mut chunked = group.create_frame(net_frame)?;
6768
chunked.write(header.freeze())?;

rs/moq-mux/src/container/fmp4/import.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -613,6 +613,7 @@ impl Import {
613613
let mut frame = g.create_frame(moq_net::Frame {
614614
size: fragment_bytes.len() as u64,
615615
timestamp: Some(timestamp),
616+
compression: moq_net::Compression::None,
616617
})?;
617618
frame.write(fragment_bytes)?;
618619
frame.finish()?;

rs/moq-native/tests/broadcast.rs

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ async fn lite05_timestamp_roundtrip(scheme: &str) {
140140
let frame = moq_native::moq_net::Frame {
141141
size: payload.len() as u64,
142142
timestamp: Some(Timestamp::new(us, Timescale::MICRO).unwrap()),
143+
compression: moq_native::moq_net::Compression::None,
143144
};
144145
let mut writer = group.create_frame(frame).expect("failed to create frame");
145146
writer
@@ -257,6 +258,7 @@ async fn lite05_fetch_roundtrip(scheme: &str) {
257258
let frame = moq_native::moq_net::Frame {
258259
size: payload.len() as u64,
259260
timestamp: Some(Timestamp::new(us, Timescale::MICRO).unwrap()),
261+
compression: moq_native::moq_net::Compression::None,
260262
};
261263
let mut writer = group.create_frame(frame).expect("failed to create frame");
262264
writer
@@ -353,6 +355,126 @@ async fn broadcast_moq_lite_05_fetch_webtransport() {
353355
lite05_fetch_roundtrip("https").await;
354356
}
355357

358+
/// A modern (lite-05) subscriber caches a `compress` track's frames in their
359+
/// Deflate-compressed form: the model holds the packed bytes (so its byte count
360+
/// matches the wire/compressed size and exposes the codec), and only inflates
361+
/// them when the payload is actually read. This is the relay-billing invariant —
362+
/// the cache counts compressed bytes — and the decode-at-delivery contract.
363+
async fn lite05_compress_caches_compressed(scheme: &str) {
364+
use moq_net::{Compression, Timescale, Timestamp};
365+
366+
let pub_origin = Origin::random().produce();
367+
let mut broadcast = pub_origin.create_broadcast("test").expect("failed to create broadcast");
368+
let mut track = broadcast
369+
.create_track(
370+
"meta",
371+
moq_net::TrackInfo::default()
372+
.with_timescale(Timescale::MICRO)
373+
.with_compress(true),
374+
)
375+
.expect("failed to create track");
376+
377+
// Highly compressible payload so the cached (compressed) length is
378+
// unmistakably smaller than the decoded payload. Written verbatim, the way an
379+
// origin produces frames (the publisher compresses on egress).
380+
let payload = bytes::Bytes::from(vec![b'a'; 4096]);
381+
let mut group = track.append_group().expect("failed to append group");
382+
let mut writer = group
383+
.create_frame(moq_net::Frame {
384+
size: payload.len() as u64,
385+
timestamp: Some(Timestamp::new(1_000, Timescale::MICRO).unwrap()),
386+
compression: Compression::None,
387+
})
388+
.expect("failed to create frame");
389+
writer.write(payload.clone()).expect("failed to write frame");
390+
writer.finish().expect("failed to finish frame");
391+
group.finish().expect("failed to finish group");
392+
393+
let mut server_config = moq_native::ServerConfig::default();
394+
server_config.bind = Some("[::]:0".to_string());
395+
server_config.tls.generate = vec!["localhost".into()];
396+
server_config.version = vec!["moq-lite-05-wip".parse().unwrap()];
397+
let mut server = server_config.init().expect("failed to init server");
398+
let addr = server.local_addr().expect("failed to get local addr");
399+
400+
let sub_origin = Origin::random().produce();
401+
let mut announcements = sub_origin.consume().announced();
402+
403+
let mut client_config = moq_native::ClientConfig::default();
404+
client_config.tls.disable_verify = Some(true);
405+
client_config.version = vec!["moq-lite-05-wip".parse().unwrap()];
406+
let client = client_config.init().expect("failed to init client");
407+
let url: url::Url = format!("{scheme}://localhost:{}", addr.port()).parse().unwrap();
408+
409+
let server_handle = tokio::spawn(async move {
410+
let request = server.accept().await.expect("no incoming connection");
411+
let session = request.with_publisher(&pub_origin).ok().await?;
412+
let _broadcast = broadcast;
413+
let _track = track;
414+
let _ = session.closed().await;
415+
Ok::<_, anyhow::Error>(())
416+
});
417+
418+
let client = client.with_subscriber(sub_origin);
419+
let session = tokio::time::timeout(TIMEOUT, client.connect(url))
420+
.await
421+
.expect("client connect timed out")
422+
.expect("client connect failed");
423+
424+
let (path, bc) = tokio::time::timeout(TIMEOUT, announcements.next())
425+
.await
426+
.expect("announce timed out")
427+
.expect("origin closed");
428+
assert_eq!(path.as_str(), "test");
429+
let bc = bc.broadcast().expect("expected announce, got unannounce");
430+
431+
let mut track_sub = tokio::time::timeout(TIMEOUT, async {
432+
bc.track("meta").unwrap().subscribe(None).unwrap().await
433+
})
434+
.await
435+
.expect("subscribe timed out")
436+
.expect("subscribe failed");
437+
assert!(track_sub.info().compress, "track should be compress-hinted");
438+
439+
let mut group_sub = tokio::time::timeout(TIMEOUT, track_sub.next_group())
440+
.await
441+
.expect("next_group timed out")
442+
.expect("next_group failed")
443+
.expect("expected a group");
444+
445+
let mut frame_sub = tokio::time::timeout(TIMEOUT, group_sub.next_frame())
446+
.await
447+
.expect("next_frame timed out")
448+
.expect("next_frame failed")
449+
.expect("expected a frame");
450+
451+
// The cache holds the Deflate-compressed bytes, not the plaintext: the codec
452+
// is recorded and the stored size is the compressed length.
453+
assert_eq!(frame_sub.compression, Compression::Deflate);
454+
assert!(
455+
(frame_sub.size as usize) < payload.len(),
456+
"cached size {} should be the compressed length, smaller than {}",
457+
frame_sub.size,
458+
payload.len()
459+
);
460+
461+
// Reading inflates the cached bytes back to the original payload.
462+
let decoded = frame_sub.read_all().await.expect("failed to read frame");
463+
assert_eq!(decoded, payload);
464+
465+
drop(session);
466+
server_handle
467+
.await
468+
.expect("server task panicked")
469+
.expect("server task failed");
470+
}
471+
472+
#[tracing_test::traced_test]
473+
#[tokio::test]
474+
async fn broadcast_moq_lite_05_compress_caches_compressed_webtransport() {
475+
lite05_compress_caches_compressed("https").await;
476+
}
477+
356478
/// A fetch must be served while a live subscription is active on the same track.
357479
/// The relay subscribes starting at the latest group, so an older group isn't
358480
/// cached and the fetch has to issue a wire FETCH concurrently with the
@@ -365,6 +487,7 @@ async fn lite05_fetch_during_subscribe(scheme: &str) {
365487
moq_net::Frame {
366488
size: payload.len() as u64,
367489
timestamp: Some(Timestamp::new(us, Timescale::MICRO).unwrap()),
490+
compression: moq_net::Compression::None,
368491
}
369492
}
370493

rs/moq-net/src/ietf/publisher.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,27 @@ impl<S: web_transport_trait::Session> Publisher<S> {
331331
stream.encode(&0u64).await?;
332332
}
333333

334+
// IETF carries no per-frame compression, so a cached compressed frame
335+
// (e.g. relayed from a lite-05 origin) must be inflated for this peer.
336+
if !frame.compression.is_none() {
337+
let mut payload = tokio::select! {
338+
biased;
339+
_ = stream.closed() => return Err(Error::Cancel),
340+
payload = frame.read_all() => payload?,
341+
};
342+
let n = payload.len() as u64;
343+
stream.encode(&n).await?;
344+
track_stats.frame();
345+
if n == 0 {
346+
// Have to write the object status too.
347+
stream.encode(&0u8).await?;
348+
} else {
349+
stream.write_all(&mut payload).await?;
350+
track_stats.bytes(n);
351+
}
352+
continue;
353+
}
354+
334355
// Write the size of the frame.
335356
stream.encode(&frame.size).await?;
336357
track_stats.frame();

rs/moq-net/src/ietf/subscriber.rs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@ use std::collections::{HashMap, hash_map::Entry};
33
use std::sync::Arc;
44

55
use crate::{
6-
BroadcastDynamic, BroadcastInfo, Error, Frame, FrameProducer, Group, GroupProducer, MAX_FRAME_SIZE, OriginProducer,
7-
OriginPublish, Path, PathOwned, StatsHandle, SubscriberStats, SubscriberTrack, TrackProducer, TrackRequest,
6+
BroadcastDynamic, BroadcastInfo, Compression, Error, Frame, FrameProducer, Group, GroupProducer, MAX_FRAME_SIZE,
7+
OriginProducer, OriginPublish, Path, PathOwned, StatsHandle, SubscriberStats, SubscriberTrack, TrackProducer,
8+
TrackRequest,
89
coding::{Reader, Stream},
910
ietf::{self, Control, FilterType, GroupOrder, RequestId},
1011
model::BroadcastProducer,
@@ -817,6 +818,7 @@ impl<S: web_transport_trait::Session> Subscriber<S> {
817818
let mut frame = producer.create_frame(Frame {
818819
size: 0,
819820
timestamp: None,
821+
compression: Compression::None,
820822
})?;
821823
track_stats.frame();
822824
frame.finish()?;
@@ -829,7 +831,11 @@ impl<S: web_transport_trait::Session> Subscriber<S> {
829831
if size > MAX_FRAME_SIZE {
830832
return Err(Error::FrameTooLarge);
831833
}
832-
let mut frame = producer.create_frame(Frame { size, timestamp: None })?;
834+
let mut frame = producer.create_frame(Frame {
835+
size,
836+
timestamp: None,
837+
compression: Compression::None,
838+
})?;
833839
track_stats.frame();
834840

835841
if let Err(err) = self.run_frame(stream, frame.clone(), &track_stats).await {

rs/moq-net/src/lite/publisher.rs

Lines changed: 34 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -788,25 +788,25 @@ async fn write_fetch_frame<W: web_transport_trait::SendStream>(
788788
) -> Result<(), Error> {
789789
encode_frame_timing(writer, frame, timescale, prev_ts).await?;
790790

791-
match compression {
792-
Compression::None => {
793-
writer.encode(&frame.size).await?;
794-
track_stats.frame();
795-
while let Some(mut chunk) = frame.read_chunk().await? {
796-
let n = chunk.len() as u64;
797-
writer.write_all(&mut chunk).await?;
798-
track_stats.bytes(n);
799-
}
800-
}
801-
compression => {
802-
let payload = frame.read_all().await?;
803-
let mut chunk = bytes::Bytes::from(compression.compress(&payload));
791+
if frame.compression == compression {
792+
// Cached codec matches the wire: forward the bytes verbatim.
793+
writer.encode(&frame.size).await?;
794+
track_stats.frame();
795+
while let Some(mut chunk) = frame.read_chunk().await? {
804796
let n = chunk.len() as u64;
805-
writer.encode(&n).await?;
806-
track_stats.frame();
807797
writer.write_all(&mut chunk).await?;
808798
track_stats.bytes(n);
809799
}
800+
} else {
801+
// Recode for this peer: `read_all` decodes the cached bytes, then we
802+
// re-encode for the wire (exactly one side is a real op; the other is None).
803+
let payload = frame.read_all().await?;
804+
let mut chunk = bytes::Bytes::from(compression.compress(&payload));
805+
let n = chunk.len() as u64;
806+
writer.encode(&n).await?;
807+
track_stats.frame();
808+
writer.write_all(&mut chunk).await?;
809+
track_stats.bytes(n);
810810
}
811811

812812
Ok(())
@@ -960,10 +960,12 @@ impl<S: web_transport_trait::Session> Subscription<S> {
960960
Ok(())
961961
}
962962

963-
/// Send one frame. Uncompressed frames stream chunk-by-chunk so we never
964-
/// buffer the whole payload; a compressed frame must buffer to feed the
965-
/// codec, and its wire size becomes the compressed length (the subscriber
966-
/// inflates it from the track's codec, known from TRACK_INFO on lite-05+).
963+
/// Send one frame. When the cached codec already matches the wire codec the
964+
/// bytes stream through verbatim, never buffering the whole payload — this is
965+
/// the hot path, and it's where a modern peer pulling an already-compressed
966+
/// track avoids a needless inflate/deflate. Otherwise the payload is decoded
967+
/// and re-encoded for this peer (an origin compressing its plaintext on egress,
968+
/// or a compression-incapable peer that needs plain bytes), which buffers it.
967969
async fn serve_frame(
968970
&mut self,
969971
stream: &mut Writer<S::SendStream, Version>,
@@ -973,22 +975,22 @@ impl<S: web_transport_trait::Session> Subscription<S> {
973975
) -> Result<(), Error> {
974976
encode_frame_timing(stream, &frame, self.timescale, prev_ts).await?;
975977

976-
match self.compression {
977-
Compression::None => {
978-
stream.encode(&frame.size).await?;
979-
self.track_stats.frame();
978+
if frame.compression == self.compression {
979+
stream.encode(&frame.size).await?;
980+
self.track_stats.frame();
980981

981-
while let Some(chunk) = self.read_chunk(stream, priority, &mut frame).await? {
982-
self.write_chunk(stream, priority, chunk).await?;
983-
}
984-
}
985-
compression => {
986-
let payload = self.read_all(stream, priority, &mut frame).await?;
987-
let chunk = bytes::Bytes::from(compression.compress(&payload));
988-
stream.encode(&(chunk.len() as u64)).await?;
989-
self.track_stats.frame();
982+
while let Some(chunk) = self.read_chunk(stream, priority, &mut frame).await? {
990983
self.write_chunk(stream, priority, chunk).await?;
991984
}
985+
} else {
986+
// `read_all` decodes the cached bytes to the logical payload; we then
987+
// re-encode in the wire codec. Codecs differ, so exactly one of decode
988+
// and encode is a real operation (the other side is `None`).
989+
let payload = self.read_all(stream, priority, &mut frame).await?;
990+
let chunk = bytes::Bytes::from(self.compression.compress(&payload));
991+
stream.encode(&(chunk.len() as u64)).await?;
992+
self.track_stats.frame();
993+
self.write_chunk(stream, priority, chunk).await?;
992994
}
993995

994996
Ok(())

rs/moq-net/src/lite/subscriber.rs

Lines changed: 16 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -646,34 +646,24 @@ impl<S: web_transport_trait::Session> Subscriber<S> {
646646
return Err(Error::FrameTooLarge);
647647
}
648648

649-
match compression {
650-
Compression::None => {
651-
let mut frame = group.create_frame(Frame { size, timestamp })?;
652-
track_stats.frame();
653-
654-
if let Err(err) = self.run_frame(stream, &mut frame, &track_stats).await {
655-
let _ = frame.abort(err.clone());
656-
return Err(err);
657-
}
649+
// Cache the bytes exactly as they arrive, tagged with the negotiated
650+
// codec. For a compressed track the cache holds the packed bytes (no
651+
// inflate on ingress): a relay forwards them verbatim to a modern peer
652+
// and only the model decodes for one that can't speak the codec. `size`
653+
// is the on-wire (stored) length in either case.
654+
let mut frame = group.create_frame(Frame {
655+
size,
656+
timestamp,
657+
compression,
658+
})?;
659+
track_stats.frame();
658660

659-
frame.finish()?;
660-
}
661-
compression => {
662-
// `size` is the compressed length; pull it off the wire, then
663-
// inflate. The frame the consumer sees carries the original size.
664-
let packed = stream.read_exact(size as usize).await?;
665-
track_stats.frame();
666-
track_stats.bytes(size);
667-
668-
let payload = compression.decompress(&packed)?;
669-
let mut frame = group.create_frame(Frame {
670-
size: payload.len() as u64,
671-
timestamp,
672-
})?;
673-
frame.write(bytes::Bytes::from(payload))?;
674-
frame.finish()?;
675-
}
661+
if let Err(err) = self.run_frame(stream, &mut frame, &track_stats).await {
662+
let _ = frame.abort(err.clone());
663+
return Err(err);
676664
}
665+
666+
frame.finish()?;
677667
}
678668

679669
Ok(())

rs/moq-net/src/model/compression.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,11 @@ impl Compression {
5858
}
5959
}
6060

61+
/// True for [`Compression::None`] (frames stored/sent verbatim).
62+
pub fn is_none(&self) -> bool {
63+
matches!(self, Self::None)
64+
}
65+
6166
/// The varint code used on the wire.
6267
pub fn to_code(self) -> u64 {
6368
match self {

0 commit comments

Comments
 (0)