Skip to content

feat(json): a sliding-window mode, appended to the back and dropped from the front - #3168

Open
kixelated wants to merge 1 commit into
devfrom
quest/m1/archive/json-window
Open

feat(json): a sliding-window mode, appended to the back and dropped from the front#3168
kixelated wants to merge 1 commit into
devfrom
quest/m1/archive/json-window

Conversation

@kixelated

Copy link
Copy Markdown
Collaborator

Summary

Neither existing mode fits a bounded, joinable run of records. stream keeps a log forever in one group, so a long-running publisher outgrows the group cache budget and every new consumer fails with Lagged. snapshot keeps only the latest value.

The obvious fix — roll the log's group and re-seed the new one with the records still retained — breaks the reader. A stream consumer yields every frame in order across group boundaries, so re-seeded records are indistinguishable from new ones and a consumer that was keeping up receives them twice. This adds a mode where the restatement is explicit, so a reader can tell "you already have these" from "here is another one".

Wire

Every frame is a tagged op:

  • {"reset":{"offset":N,"records":[...]}} opens every group, naming the retained records and the absolute index of the first.
  • {"push":REC} appends to the back.
  • {"pop":N} drops from the front.

Only the reset carries an index. A push takes the next one and a pop drops from the front, both positional against the reset — sound because the group-scoped DEFLATE window already makes a mid-group join undecodable, so a reader never sees a subset of a group's frames.

Trimming is therefore an op, not a group boundary. Dropping a record costs one small frame inside the shared compression window, rather than a roll that would throw that window away.

Rolling is invisible

The producer rolls when a group's ops outgrow op_ratio times the reset that opened it, exactly as snapshot rolls on its delta budget, with the same 256-frame cap. That is purely a compression decision: no caller-driven cut, no age bound, and the consumer never surfaces it. A reset restating records already delivered yields nothing, so however often the publisher rolls, a reader sees one continuous stream of events. rolling_is_invisible_to_the_consumer pins this by running the same edits at op_ratio 1000 and 0 and comparing the event streams.

What a reader is told

Every index is reported exactly once: Push on arrival, Pop on leaving, Skip for a record that existed but was dropped before this reader saw it. A reader that joins late, or falls a group behind, learns from the reset's offset what it will never receive rather than silently missing it. A fresh reader adopts the first reset's offset instead of skipping all of history.

Two decisions worth review

  • Records are stored pre-encoded (Value in Rust, JSON text in TS), and a reset splices them in rather than re-serializing. So a record's bytes are identical whether a reader gets it as a push or in a later reset.
  • A failed write does not roll the window back. The edit really happened — the record is gone from the publisher's window — so only the consumer's knowledge is lost, and the next reset repairs it. Rolling back would desync the publisher from its own retention.

Public API changes

Purely additive; no existing item renamed, removed, or signature-changed.

  • New moq_json::window module: Producer, Consumer, Encoder, Decoder, Event, Encoded, Pending, ProducerConfig, ConsumerConfig.
  • New moq_json::Error::MissingReset variant (the enum is #[non_exhaustive]).
  • New @moq/json Window export with the matching surface.

Wire behavior changes

None to any existing mode. This is a new track format that nothing in-tree publishes or consumes yet; the broadcast timeline moves onto it separately, which is where the hang draft's Track Framing gets updated.

Test plan

  • just check and just test (1949 tests, 1 skipped) green.
  • 11 Rust and 16 TS tests, including: push/pop round-trip, the sliding window, a popped record never being restated even at op_ratio: 0 (where every edit restates the window), rolling being invisible, compressed round-trip across many rolls, a fresh consumer adopting the offset without spurious skips, and a lagging consumer being told what it missed with no gaps in the reported indices.
  • rs/moq-json/tests/window-vectors.json pins the exact frame bytes for all six op shapes, asserted from both suites, following the existing differ-vector precedent.

(written by claude-opus-5)

…rom the front

Neither existing mode fits a bounded, joinable run of records. `stream`
keeps a log forever in one group, so a long-running publisher outgrows the
group cache budget and every new consumer fails with `Lagged`. `snapshot`
keeps only the latest value.

The obvious fix -- roll the log's group and re-seed the new one with the
records still retained -- breaks the reader. Re-seeded records are
indistinguishable from new ones on the wire, so a consumer that was keeping
up receives them twice. Add a mode where the restatement is explicit
instead, so a reader can tell "you already have these" from "here is
another one".

Every frame is a tagged op. Each group opens with a `reset` naming the
retained records and the absolute offset of the first, followed by `push`
and `pop`. Only the reset carries an index; a push takes the next one and a
pop drops from the front, both positional against the reset, which is sound
because the group-scoped DEFLATE window already makes a mid-group join
undecodable. Trimming is therefore an op, not a group boundary: dropping a
record costs one small frame inside the shared compression window rather
than a roll that would throw that window away.

The producer rolls when a group's ops outgrow `op_ratio` times the reset
that opened it, exactly as `snapshot` rolls on its delta budget. That is
purely a compression decision, and the consumer never surfaces it: a reset
restating records already delivered yields nothing, so however often the
publisher rolls, a reader sees one continuous stream of events.

Every index is reported exactly once -- `Push` on arrival, `Pop` on leaving,
and `Skip` for a record that existed but was dropped before this reader saw
it. A reader that joins late or falls a group behind learns from the reset's
offset what it will never receive, rather than silently missing it.

Records are stored pre-encoded, so a record's bytes are identical whether a
reader gets it as a push or in a later reset. A failed write does not roll
the window back: the edit really happened, only the consumer's knowledge of
it is lost, and the next reset repairs that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 88fb95fed3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +141 to +142
for index in delivered..offset {
self.events.push_back(Event::Skip(index));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bound event generation for skipped index ranges

When a decoder has processed one valid reset, a later tiny reset can set offset arbitrarily far ahead and this loop eagerly allocates one Event::Skip per intervening index before decode returns. A remote publisher can therefore hang or exhaust the consumer with two small frames, such as offsets 0 and u64::MAX; the matching TypeScript loop in js/json/src/window/decoder.ts also blocks the browser main thread. Represent the gap compactly or enforce a bounded advance instead of materializing an untrusted range.

Useful? React with 👍 / 👎.

Comment on lines +207 to +210
ratio != 0
&& self.group_frames > 0
&& self.group_frames < MAX_GROUP_FRAMES
&& self.op_bytes <= ratio * self.reset_len

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Bound group rolls by the byte cache

For large records, this permits a group to exceed moq-net's 32 MiB byte cache even though it stays below the 256-frame and ratio limits. For example, a 16 MiB reset followed by a 15 MiB push, a pop of the original record, and another 15 MiB push leaves a restatable 30 MiB window but a roughly 46 MiB group, so rs/moq-net/src/model/group.rs::MAX_GROUP_CACHE evicts the reset and a late reader receives Lagged instead of joining. Track cumulative group bytes, including the pending op, and roll before the reset can be evicted; the matching TypeScript budget has the same defect.

Useful? React with 👍 / 👎.

Comment on lines +91 to +93
pub fn reset(&mut self) {
self.flate = None;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require a reset after every decoder reset

After the decoder has consumed its first group, this method leaves delivered populated, so an uncompressed push or pop as the first frame of a later group passes the MissingReset check and is silently applied against the previous group's position. Consumer::poll_next calls this method at every group boundary, so a malformed or incorrectly routed group produces corrupt indices rather than the documented error. Preserve the cross-group delivery cursor, but separately clear and check whether the current group has received its reset.

Useful? React with 👍 / 👎.

Comment on lines +154 to +158
const dropped = Math.min(count, this.#window.length);
if (dropped <= 0) return undefined;

this.#window.splice(0, dropped);
this.#offset += dropped;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject non-integral pop counts

When callers pass a fractional or non-finite JavaScript number, Math.min preserves it while splice coerces it differently. For example, pop(0.5) removes no array element but advances the offset by 0.5 and emits {"pop":0.5}, immediately desynchronizing the producer and consumer; NaN additionally creates an invalid reset containing NaN. Validate that count is a nonnegative safe integer, or expose an integer-constrained type, before mutating state.

AGENTS.md reference: AGENTS.md:L165-L168

Useful? React with 👍 / 👎.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-08-29T04:55:01.520282Z 88fb95f PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

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