Skip to content

feat(moq-net): per-hop, per-frame payload compression (drafts#39)#1874

Open
kixelated wants to merge 4 commits into
devfrom
claude/compress-frame-cache
Open

feat(moq-net): per-hop, per-frame payload compression (drafts#39)#1874
kixelated wants to merge 4 commits into
devfrom
claude/compress-frame-cache

Conversation

@kixelated

@kixelated kixelated commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator

Implements the moq-lite half of moq-dev/drafts#39: the publisher only signals whether a track is worth compressing, while the algorithm is negotiated per hop and recorded per frame. A single frame can opt out — e.g. a small JSON merge-patch delta that DEFLATE would only enlarge — and a relay forwards an already-compressed frame untouched instead of inflating/deflating it per downstream peer.

This supersedes the original "cache compressed bytes" idea (first commit, kept as the foundation): the model now stores frames in whatever codec each one names, and decodes only at delivery.

Wire changes (moq-lite-05-wip)

  • TRACK_INFO Publisher Compression → a boolean hint (0/1; >1 reserved, decodes as true so it stays additive). Names no algorithm.
  • New SETUP Compression parameter (id 0x3): each endpoint advertises the algorithms it can decompress (packed varints, none=0 omitted), in preference order. Per hop, per direction; never forwarded by a relay. Both Rust and JS advertise [deflate].
  • New per-frame Compression field in FRAME, present iff the track is hinted, after the timestamp delta and before the length. Names the codec used (none/deflate); it doubles as the group's end-of-frames sentinel when no timestamp precedes it.

No datagram path exists in moq-net yet (that part of the draft is N/A here).

Behavior

  • Subscriber decodes the per-frame field and caches each frame in that codec, so the cache holds the compressed bytes (the billing/wire byte count) and inflates only at delivery (FrameConsumer::read_all); read_chunk streams the stored bytes verbatim for the relay path.
  • Publisher reads the peer's advertised algorithms (PeerSetup) before serving a hinted track. It forwards an already-DEFLATE cached frame verbatim when the peer can inflate it (relay passthrough — no inflate/deflate), and otherwise picks per frame whether DEFLATE actually shrinks the payload, sending none when it wouldn't (or the peer can't inflate). Empty payloads always use none.
  • An origin marks a track compress but writes plaintext, so its cached frames stay None and the publisher compresses on egress; a relay caches whatever its upstream sent and forwards/decodes per frame. Both are correct because the codec lives per-frame on Frame::compression, distinct from the compress hint.

Cross-package (Rust + JS, kept in sync)

  • rs/moq-net — the reference implementation (above).
  • js/net — mirrors the wire so the browser stays interoperable (the catalog is a compress track). The browser is a consumer, so it keeps decompressing on ingress and just parses the new wire: boolean hint, SETUP Compression param, per-frame field; its publisher reads the peer's SETUP and picks DEFLATE per frame only when it shrinks.
  • doc/ — no existing compression page to update (the spec lives in the drafts repo); refreshed the stale "negotiated in SUBSCRIBE_OK" wording in both compression module docs.
  • IETF moqt moq-compression extension: left for a follow-up; the IETF path keeps sending verbatim (spec-compliant "extension not negotiated").

Public API changes — breaking, targets dev

  • rs/moq-net: Frame gains compression: Compression; FrameConsumer::read_all returns the decoded payload (was verbatim) while read_chunk stays verbatim; Compression::is_none added; lite::TrackInfo.compressioncompress: bool; lite::Setup gains compression: Vec<Compression> + PeerSetup::compression().
  • js/net: lite TrackInfo.compressioncompress: boolean; Setup gains compression: Compression[] (both internal to the lite module).

Test plan

  • Rust: moq-net unit (401) incl. TRACK_INFO additive-hint, SETUP param round-trip + skip-none/unknown, model decode-vs-verbatim / reject-garbage; moq-native e2e (63) incl. a real lite-05 session where a compressible frame is cached DEFLATE while a tiny one opts out (None), both inflating back correctly; moq-relay (129); cargo check --workspace --tests, fmt + clippy -D warnings + cargo doc (via nix) — clean.
  • JS: @moq/net 192 tests (incl. new SETUP Compression param round-trip + skip-none/unknown and TRACK_INFO additive-hint), tsc --noEmit clean, Biome clean; @moq/hang typecheck clean.

🤖 Generated with Claude Code

(Written by Claude)

kixelated and others added 2 commits June 22, 2026 12:11
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>
Implements the moq-lite half of moq-dev/drafts#39: the publisher only signals
*whether* a track is worth compressing, while the algorithm is negotiated per hop
and recorded per frame. A single frame can opt out — e.g. a small JSON merge-patch
delta that DEFLATE would only enlarge — instead of bloating it.

Wire (moq-lite-05-wip):
- TRACK_INFO `Publisher Compression` becomes a boolean hint (>1 reserved, decodes
  as true so it stays additive). It names no algorithm.
- New SETUP `Compression` parameter (id 0x3): each endpoint advertises the
  algorithms it can decompress, packed varints, none(0) omitted. moq-net advertises
  [deflate]. Per hop, per direction; never forwarded by a relay.
- New per-frame `Compression` field in FRAME, present iff the track is hinted,
  after the timestamp delta and before the length. Names the codec actually used
  (none/deflate); it doubles as the group's end-of-frames sentinel when no
  timestamp precedes it.

Behavior:
- The publisher reads the peer's advertised algorithms (PeerSetup) before serving a
  compress-hinted track. It forwards an already-DEFLATE cached frame verbatim when
  the peer can inflate it (relay passthrough, no inflate/deflate), and otherwise
  picks per frame whether DEFLATE actually shrinks the payload, sending `none` when
  it wouldn't (or the peer can't inflate). Empty payloads always use `none`.
- The subscriber decodes the per-frame field and caches each frame in that codec,
  so the cache holds compressed bytes (billing counts the wire size) and inflates
  only at delivery.

No datagram path exists in moq-net yet, so that part of the draft is N/A here. The
IETF moqt compression extension (moq-compression) is left for a follow-up; the IETF
path keeps sending verbatim, which is spec-compliant "extension not negotiated".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@kixelated kixelated changed the title feat(moq-net): cache compressed frame payloads, decode at delivery feat(moq-net): per-hop, per-frame payload compression (drafts#39) Jun 22, 2026
kixelated and others added 2 commits June 22, 2026 14:44
Keeps the browser client interoperable with the updated relay on the lite-05-wip
wire (the catalog is a `compress` track, so a stale browser would otherwise
misparse it):

- TRACK_INFO `Publisher Compression` becomes a boolean hint (`track.ts`).
- New SETUP `Compression` parameter (`setup.ts`): we advertise `[deflate]`; the
  parameter is packed varints, none(0) omitted, unknown ids ignored on decode.
- Per-frame `Compression` field: the subscriber reads it (gated on the hint) and
  decodes each frame by its own codec; the publisher writes it and picks DEFLATE
  per frame only when the peer advertised it and it shrinks the payload.

The browser stays a consumer (decompress on ingress), so it doesn't adopt the
relay's store-compressed model; it only needs to parse the new wire. The publisher
reads the peer's SETUP (threaded from Connection) before compressing egress.

Also refreshes the stale "negotiated in SUBSCRIBE_OK" wording in both compression
module docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e None variant

`Compression::None` was an awkward "compression that doesn't compress." Verbatim
transfer is the *absence* of a codec, so model it as `Option::None` (Rust) /
`undefined` (JS) and leave the enum with just real codecs (`Deflate`). This makes
"compress with nothing" unrepresentable and stops a negotiated algorithm list from
carrying a meaningless none.

- `Compression` enum loses `None`; `compress`/`decompress` take `self` and only do
  real work (callers handle the verbatim case via the `Option`).
- `from_code` returns `Option<Compression>` (`0` -> `None`); `to_code` is always
  non-zero. `is_none()` is dropped in favor of `Option::is_none`.
- `Frame::compression` is now `Option<Compression>`; the wire/SETUP/per-frame-field
  code threads the `Option` (e.g. `codec.map_or(0, Compression::to_code)`), and the
  SETUP list is naturally `Vec<Compression>` with no none to filter.
- JS mirrors it: `Compression` drops `None`, "no codec" is `undefined`.

Pure internal refactor: the wire bytes (codes 0/1) are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant