From 33ae93f4017b8c9700b30c24ebb8af62ad110024 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 5 Oct 2026 12:27:39 -0700 Subject: [PATCH 1/5] docs(quest): stats totals and per-broadcast tracks replace the per-path maps Co-Authored-By: Claude Opus 5.5 --- quest/m0/broadcast-epoch/README.md | 1 + quest/m1/README.md | 1 + quest/m1/stats-split.md | 51 ++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+) create mode 100644 quest/m1/stats-split.md diff --git a/quest/m0/broadcast-epoch/README.md b/quest/m0/broadcast-epoch/README.md index 9aba157707..fad38a49f2 100644 --- a/quest/m0/broadcast-epoch/README.md +++ b/quest/m0/broadcast-epoch/README.md @@ -83,4 +83,5 @@ This README owns: - [GStreamer and OBS](/quest/m0/broadcast-epoch/gst-obs.md) - moqsink and the OBS plugin publish each run under a fresh epoch - [Remove `--hop`](/quest/m0/broadcast-epoch/hop-removal.md) - `moq` takes an optional `--epoch` instead of `--hop`, a plain publisher declares a random Hop ID, and the per-session hop stamp and NO_CAPACITY are gone - [Stats epochs](/quest/m1/stats-epoch.md) - moq-stats publishes each node under its own epoch, so a restarted node never stalls its viewers +- [Stats totals and per-broadcast tracks](/quest/m1/stats-split.md) - the same release retires the per-path stats maps for totals and per-broadcast tracks (decided 2026-10-05) - [Retracted demand release](/quest/m1/unannounce-demand-release.md) - a retracted broadcast's track demand is released when its last subscriber leaves, as before #4741 diff --git a/quest/m1/README.md b/quest/m1/README.md index 7d88a08adc..e092d2eeca 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -25,6 +25,7 @@ QUIC studies there on that rule. - [Retracted demand release](/quest/m1/unannounce-demand-release.md) - a retracted broadcast's track demand is released when its last subscriber leaves, as before #4741 - [Bounded stats aggregate](/quest/m1/stats-aggregate-bound.md) - the stats aggregator folds departed nodes into a retired total after a grace window, so its memory stops growing with node churn - [Stats epochs](/quest/m1/stats-epoch.md) - each stats producer publishes under `.../node//@`, so a restarted node never reuses a name +- [Stats totals and per-broadcast tracks](/quest/m1/stats-split.md) - each node publishes unpruned per-group totals and a per-broadcast track on request, so a lagging reader loses nothing - [Cluster routing](/quest/m1/cluster-routing/README.md) - any node routes toward a broadcast's origin over CDN and P2P links alike, with per-origin routes and path-less announces - [Transcoders start at group boundaries](/quest/m1/transcode-group-start.md) - moq-transcode serves from a group boundary and mirrors its source's sequences, so two instances are interchangeable - [One route cost](/quest/m1/route-cost.md) - Warm and Cold collapse to one static route cost diff --git a/quest/m1/stats-split.md b/quest/m1/stats-split.md new file mode 100644 index 0000000000..526be7c36a --- /dev/null +++ b/quest/m1/stats-split.md @@ -0,0 +1,51 @@ +# [L] Stats totals and per-broadcast tracks + +## Goal + +Each node's stats broadcast publishes per-group cumulative totals that are +never pruned, plus one stats track per broadcast, served only when requested. +The per-path map tracks are gone. A reader that misses a group loses nothing: +the next totals frame still carries everything since the epoch began. + +## Plan + +Decided 2026-10-05 (planned from moq-dev/moq.pro#2202): + +- **Why.** Every counter is cumulative per entry, and an entry is pruned right + after the frame carrying its closing readout. A broadcast that starts and + ends inside a group a reader missed shows in no later frame, so a lagging + reader undercounts. The maps also grow with every live broadcast, and + readers like billing want only per-project sums. +- **Totals.** Per group broadcast (`[/]/node//@`), + per tier and role, cumulative within the [stats epoch](/quest/m1/stats-epoch.md). + A node starts a new epoch on every startup and counts from zero; nothing is + serialized to disk. `Registry` already keeps unpruned lifetime totals + (`rs/moq-net/src/stats.rs`); publish them per group. +- **Per-broadcast tracks.** A reader that wants one broadcast subscribes to + its track; nothing is produced for a broadcast no one requests. The track + carries that broadcast's cumulative counters and finishes after its closing + readout. Pick the track naming while implementing; the existing + `MAX_REQUESTED_TRACKS` and `MAX_PARKED_REQUESTS` caps apply. +- **Retire the map tracks** (`publisher.json`, `subscriber.json`, and their + `.json.z` siblings) in the same release. +- Ship in the same breaking release as stats epochs, so consumers take one + stats path and wire change, not two. +- Update the aggregator and `doc/concept/stats.md` with the change. + +Public API: `moq-stats` producer and consumer types. Wire: stats track names +and payloads. + +MoQ Pro adopts it when it pins the release: billing reads totals, its +Broadcasts page subscribes per visible broadcast, and its `announced` probe +reads totals only. + +## Required + +- [Stats epochs](/quest/m1/stats-epoch.md) - totals are cumulative within an epoch + +## Related + +- [Bounded stats aggregate](/quest/m1/stats-aggregate-bound.md) - retired + nodes fold into a bounded total; per-epoch totals feed it +- [Media stats](/quest/m1/stats/README.md) - publisher and viewer media stats + stay hang tracks, separate from the relay's stats From 12047d3cebbd0723037b6264c2492ee402f8d6e0 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 5 Oct 2026 12:32:40 -0700 Subject: [PATCH 2/5] docs(quest): stats totals keep group attribution and outlive idle groups Co-Authored-By: Claude Opus 5.5 --- quest/m1/stats-split.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/quest/m1/stats-split.md b/quest/m1/stats-split.md index 526be7c36a..683ab6e03c 100644 --- a/quest/m1/stats-split.md +++ b/quest/m1/stats-split.md @@ -19,8 +19,16 @@ Decided 2026-10-05 (planned from moq-dev/moq.pro#2202): - **Totals.** Per group broadcast (`[/]/node//@`), per tier and role, cumulative within the [stats epoch](/quest/m1/stats-epoch.md). A node starts a new epoch on every startup and counts from zero; nothing is - serialized to disk. `Registry` already keeps unpruned lifetime totals - (`rs/moq-net/src/stats.rs`); publish them per group. + serialized to disk. `Registry`'s unpruned lifetime totals + (`rs/moq-net/src/stats.rs`) are node-wide, per tier and role only, so they + can't be published as-is: keep the totals per group, folding an entry into + its group's total when it is pruned, or two projects sharing a tier would + merge. +- A group's totals outlive its entries. Today a group broadcast disappears + when it has none (`rs/moq-stats/src/produce.rs`); its totals must survive + that, so a group that goes idle and returns in the same epoch continues + from where it stopped. Test two groups sharing a tier, and an idle group + returning. - **Per-broadcast tracks.** A reader that wants one broadcast subscribes to its track; nothing is produced for a broadcast no one requests. The track carries that broadcast's cumulative counters and finishes after its closing From ea6e04622e8061edcc2be4b3bc29ff7d7af65f00 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 5 Oct 2026 13:35:39 -0700 Subject: [PATCH 3/5] docs(quest): stats groups take a new epoch per announcement; settle idle groups and sessions Co-Authored-By: Claude Opus 5.5 --- quest/m0/broadcast-epoch/README.md | 2 +- quest/m0/broadcast-epoch/stats-epoch.md | 25 +++++++---- quest/m0/broadcast-epoch/stats-split.md | 60 ++++++++++++------------- 3 files changed, 48 insertions(+), 39 deletions(-) diff --git a/quest/m0/broadcast-epoch/README.md b/quest/m0/broadcast-epoch/README.md index 20f1e00775..7f968003ee 100644 --- a/quest/m0/broadcast-epoch/README.md +++ b/quest/m0/broadcast-epoch/README.md @@ -89,5 +89,5 @@ This README owns: - [GStreamer](/quest/m0/broadcast-epoch/gst.md) - moqsink publishes each run under a fresh epoch - [Remove `--hop`](/quest/m0/broadcast-epoch/hop-removal.md) - `moq` takes an optional `--epoch` instead of `--hop`, a plain publisher declares a random Hop ID, and the per-session hop stamp is gone - [Bounded stats aggregate](/quest/m0/broadcast-epoch/stats-aggregate-bound.md) - the stats aggregator folds departed nodes into a retired total, so epoch churn stops growing its memory -- [Stats epochs](/quest/m0/broadcast-epoch/stats-epoch.md) - moq-stats publishes each node under its own epoch, so a restarted node never stalls its viewers +- [Stats epochs](/quest/m0/broadcast-epoch/stats-epoch.md) - moq-stats publishes each group announcement under its own epoch, so neither a restarted node nor a returning idle group stalls its viewers - [Stats totals and per-broadcast tracks](/quest/m0/broadcast-epoch/stats-split.md) - the same release retires the per-path stats maps for totals and per-broadcast tracks (decided 2026-10-05) diff --git a/quest/m0/broadcast-epoch/stats-epoch.md b/quest/m0/broadcast-epoch/stats-epoch.md index d9236e330e..bd135576e2 100644 --- a/quest/m0/broadcast-epoch/stats-epoch.md +++ b/quest/m0/broadcast-epoch/stats-epoch.md @@ -2,14 +2,22 @@ ## Goal -Each stats producer publishes under its broadcast epoch, -`[/]/node//@`, so a restarted node never reuses a -broadcast name or the group numbers a relay cached under it. +Each stats group broadcast publishes under its own epoch, +`[/]/node//@`, minted each time the group is +announced, so neither a restarted node nor a group returning from idle reuses +a broadcast name or the group numbers a relay cached under it. ## Plan - Use the shared `moq_net::Epoch`, not a stats-local UUID (decided - 2026-10-02). One epoch per `Producer`, shared by all its group broadcasts. + 2026-10-02). +- One epoch per group announcement (decided 2026-10-05, replacing 2026-10-02's + one epoch per `Producer`). A group that goes idle unannounces after its + linger and drops its totals; when it returns it announces under a new epoch + counted from zero. Why: a producer never holds an idle group's state, so its + memory is bounded by active and lingering groups, and readers see every + reset as a new path rather than detecting one. The cost: a reader that + misses a group's last frame before the unannounce loses those increments. - [#4739](https://github.com/moq-dev/moq/pull/4739)'s original commits are prior art: path syntax, `Producer::epoch()`, `parse_node_path`, the aggregator's restarted-epoch handling, and the tests. The PR landed only the @@ -19,16 +27,17 @@ broadcast name or the group numbers a relay cached under it. - The aggregator treats a new epoch as a new node: its counters add to the retired total from the [bounded aggregate](/quest/m0/broadcast-epoch/stats-aggregate-bound.md) instead of regressing the merged total. -- MoQ Pro wants to share the same epoch with its storage stats, so expose it - from `Producer`. +- Expose each group broadcast's epoch so a consumer can tell epochs apart. + MoQ Pro's VOD `storage.json` mints its own epoch rather than sharing a + producer-wide one, which no longer exists. - `release` carries a temporary seed instead ([#4810](https://github.com/moq-dev/moq/pull/4810)): a producer's first group number is wall-clock microseconds, so a restarted node numbers above its previous run. Epochs replace it, so it never reaches `main`: release-to-main back-merges keep `main`'s `rs/moq-stats` and `doc/concept/stats.md`. Drop the seed and its `doc/concept/stats.md` sentence from `release` when this ships - there. MoQ Pro's VOD `storage.json` seeds the same way and moves to the - shared epoch with it. + there. MoQ Pro's VOD `storage.json` seeds the same way and moves to its + own epoch with it. - demo/web stats keys include the epoch. Update `doc/concept/stats.md`, `doc/bin/relay/config.md`, and the relay stats config docs. diff --git a/quest/m0/broadcast-epoch/stats-split.md b/quest/m0/broadcast-epoch/stats-split.md index 5d62a1c314..5c24bc9d22 100644 --- a/quest/m0/broadcast-epoch/stats-split.md +++ b/quest/m0/broadcast-epoch/stats-split.md @@ -3,9 +3,10 @@ ## Goal Each node's stats broadcast publishes per-group cumulative totals that are -never pruned, plus one stats track per broadcast, served only when requested. -The per-path map tracks are gone. A reader that misses a group loses nothing: -the next totals frame still carries everything since the epoch began. +never pruned while the group is announced, plus one stats track per broadcast, served only when requested. +The per-path map tracks are gone. While a group stays announced, a reader +that misses a frame loses nothing: the next totals frame still carries +everything since the epoch began. ## Plan @@ -18,20 +19,23 @@ Decided 2026-10-05 (planned from moq-dev/moq.pro#2202): readers like billing want only per-project sums. - **Totals.** Per group broadcast (`[/]/node//@`), per tier and role, cumulative within the [stats epoch](/quest/m0/broadcast-epoch/stats-epoch.md). - A node starts a new epoch on every startup and counts from zero; nothing is - serialized to disk. `Registry`'s unpruned lifetime totals + Every group announcement, and so every restart, starts a new epoch counted + from zero; nothing is serialized to disk. `Registry`'s unpruned lifetime totals (`rs/moq-net/src/stats.rs`) are node-wide, per tier and role only, so they can't be published as-is: keep the totals per group, folding an entry into its group's total when it is pruned, or two projects sharing a tier would merge. -- A group's totals outlive its entries. Today a group broadcast disappears - when it has none (`rs/moq-stats/src/produce.rs`); its totals must survive - that, so a group that goes idle and returns in the same epoch continues - from where it stopped. Test two groups sharing a tier, and an idle group - returning. -- The totals map grows with every distinct group a node sees in its epoch. - Accept that: an entry is a few counters per tier and role, groups are - projects, and a restart starts a new epoch from empty. +- **Idle groups** (decided 2026-10-05). Today a group broadcast disappears + the moment it has no entries (`rs/moq-stats/src/produce.rs`). Instead it + stays announced for the [stats linger](https://github.com/moq-dev/moq/pull/4843) + after its last entry ends, so a group that returns within the linger + continues its totals. After the linger it unannounces and drops its + totals; a return announces under a new [epoch](/quest/m0/broadcast-epoch/stats-epoch.md) + counted from zero. A reader that lags past the linger loses the increments + in the frames it missed; document that. Memory is bounded by active and + lingering groups. Test two groups sharing a tier, a group returning within + the linger (same epoch, totals continue), and one returning after it (new + epoch from zero). - **Per-broadcast tracks.** A reader that wants one broadcast subscribes to its track; nothing is produced for a broadcast no one requests. The track carries that broadcast's cumulative counters and finishes after its closing @@ -46,30 +50,26 @@ Decided 2026-10-05 (planned from moq-dev/moq.pro#2202): of visible broadcasts. Give per-broadcast tracks their own cap, sized for one page, and refuse requests beyond it so the reader sees an error rather than a parked track. -- **Retire the map tracks** (`publisher.json`, `subscriber.json`, and their - `.json.z` siblings) in the same release. +- **Sessions** (decided 2026-10-05). `sessions.json` is keyed by auth root + and loses pruned roots the same way, so fold per-tier `Presence` into the + totals (session counts) and serve per-root detail as a requested track, + the same model as per-broadcast tracks. +- **Retire the map tracks** (`publisher.json`, `subscriber.json`, + `sessions.json`, and their `.json.z` siblings) in the same release. - Ship in the same breaking release as stats epochs, so consumers take one stats path and wire change, not two. -- Update the aggregator and `doc/concept/stats.md` with the change. - -Open: - -- **Idle groups.** A group broadcast still unannounces once it has no - entries, so a reader that misses its last totals frame lacks those - increments until the group returns, contradicting the Goal. Either keep a - group with nonzero totals announced for the whole epoch, or keep the - unannounce (after [#4843](https://github.com/moq-dev/moq/pull/4843)'s - linger) and soften the Goal. -- **`sessions.json`.** It is keyed by auth root and loses pruned roots the - same way. Either fold per-tier `Presence` into the totals and retire it - with the other maps, or keep it as is. +- Update the aggregator and `doc/concept/stats.md` with the change. The + aggregator merges totals across nodes, and merges one broadcast's track + across nodes when a reader requests it, so a multi-node consumer serves the + same format it reads. Public API: `moq-stats` producer and consumer types. Wire: stats track names and payloads. MoQ Pro adopts it when it pins the release: billing reads totals, its -Broadcasts page subscribes per visible broadcast, and its `announced` probe -reads totals only. +Broadcasts page subscribes per visible broadcast, its `announced` probe +reads totals only, and its customer stats feed serves this format summed +across nodes. ## Required From af642ff6c752c64d1924be4ad10017172f2a94c1 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 5 Oct 2026 14:17:41 -0700 Subject: [PATCH 4/5] docs(quest): address review of stats-split at ea6e0462 Name the m0 linger quest, scope the per-broadcast cap, mark zeroed tracks non-authoritative, add demo/web as a consumer, and align stats-epoch and the bounded aggregate with per-announcement epochs. Co-Authored-By: Claude Opus 5.5 --- .../broadcast-epoch/stats-aggregate-bound.md | 3 ++ quest/m0/broadcast-epoch/stats-epoch.md | 7 +++-- quest/m0/broadcast-epoch/stats-split.md | 31 +++++++++++++------ 3 files changed, 29 insertions(+), 12 deletions(-) diff --git a/quest/m0/broadcast-epoch/stats-aggregate-bound.md b/quest/m0/broadcast-epoch/stats-aggregate-bound.md index 7fe85310c7..95b3019084 100644 --- a/quest/m0/broadcast-epoch/stats-aggregate-bound.md +++ b/quest/m0/broadcast-epoch/stats-aggregate-bound.md @@ -26,6 +26,9 @@ Decided (2026-10-02): grace-window fold. only a same-instance return after a long outage), or whether some bounded per-path baseline reconciles it. Test exact totals across that return either way, not just monotonicity. +- With an epoch per group announcement, a stats group returning from idle is + always a new path, so the grace re-arm covers only reconnects of a still + announced path, and the double-count above needs a same-epoch return. - Rejected: folding on depart double-counts a node that reconnects with its counters intact. TTL eviction without a fold makes merged totals regress. - Timers follow the repo rule: time decisions use `max(wall, pts)`. Tests mock diff --git a/quest/m0/broadcast-epoch/stats-epoch.md b/quest/m0/broadcast-epoch/stats-epoch.md index bd135576e2..8022dad69b 100644 --- a/quest/m0/broadcast-epoch/stats-epoch.md +++ b/quest/m0/broadcast-epoch/stats-epoch.md @@ -18,8 +18,10 @@ a broadcast name or the group numbers a relay cached under it. memory is bounded by active and lingering groups, and readers see every reset as a new path rather than detecting one. The cost: a reader that misses a group's last frame before the unannounce loses those increments. + At depth 0 the single broadcast never unannounces, so its epoch still lasts + the producer's life. - [#4739](https://github.com/moq-dev/moq/pull/4739)'s original commits are - prior art: path syntax, `Producer::epoch()`, `parse_node_path`, the + prior art: path syntax, `parse_node_path`, the aggregator's restarted-epoch handling, and the tests. The PR landed only the producer-wide group allocator. - Decide what an unset node becomes once the epoch follows it (#4739 used @@ -27,7 +29,8 @@ a broadcast name or the group numbers a relay cached under it. - The aggregator treats a new epoch as a new node: its counters add to the retired total from the [bounded aggregate](/quest/m0/broadcast-epoch/stats-aggregate-bound.md) instead of regressing the merged total. -- Expose each group broadcast's epoch so a consumer can tell epochs apart. +- Expose each group broadcast's epoch (a per-group accessor replacing + #4739's `Producer::epoch()`) so a consumer can tell epochs apart. MoQ Pro's VOD `storage.json` mints its own epoch rather than sharing a producer-wide one, which no longer exists. - `release` carries a temporary seed instead diff --git a/quest/m0/broadcast-epoch/stats-split.md b/quest/m0/broadcast-epoch/stats-split.md index 5c24bc9d22..0f3b664381 100644 --- a/quest/m0/broadcast-epoch/stats-split.md +++ b/quest/m0/broadcast-epoch/stats-split.md @@ -27,19 +27,24 @@ Decided 2026-10-05 (planned from moq-dev/moq.pro#2202): merge. - **Idle groups** (decided 2026-10-05). Today a group broadcast disappears the moment it has no entries (`rs/moq-stats/src/produce.rs`). Instead it - stays announced for the [stats linger](https://github.com/moq-dev/moq/pull/4843) - after its last entry ends, so a group that returns within the linger + stays announced for the stats linger (`quest/m0/stats-linger.md`, an m0 + quest planned in [#4843](https://github.com/moq-dev/moq/pull/4843); list it + under Required once it lands) after its last entry ends, so a group that returns within the linger continues its totals. After the linger it unannounces and drops its totals; a return announces under a new [epoch](/quest/m0/broadcast-epoch/stats-epoch.md) - counted from zero. A reader that lags past the linger loses the increments - in the frames it missed; document that. Memory is bounded by active and + counted from zero. Totals are cumulative, so a missed middle frame costs + nothing, but a reader that misses a group's last frame before the + unannounce loses that epoch's tail; document that, and note that billing + relies on the aggregator's grace fold to cover it. Memory is bounded by active and lingering groups. Test two groups sharing a tier, a group returning within the linger (same epoch, totals continue), and one returning after it (new epoch from zero). - **Per-broadcast tracks.** A reader that wants one broadcast subscribes to its track; nothing is produced for a broadcast no one requests. The track carries that broadcast's cumulative counters and finishes after its closing - readout. Pick the track naming while implementing, but it must be + readout. A request after the closing readout gets the zeroed track below, + so a zeroed per-broadcast track is not authoritative; totals carry the + real sums. Pick the track naming while implementing, but it must be injective: tiers and broadcast paths both contain `/`, so no plain concatenation of the two parses back to one pair. - A broadcast's counters start at zero. A request for a broadcast with no @@ -47,18 +52,24 @@ Decided 2026-10-05 (planned from moq-dev/moq.pro#2202): until one appears, as an unrecorded tier is today, and is reclaimed when its last consumer leaves. - The tier-sized caps (`MAX_REQUESTED_TRACKS` is 64) are too small for a page - of visible broadcasts. Give per-broadcast tracks their own cap, sized for - one page, and refuse requests beyond it so the reader sees an error rather - than a parked track. + of visible broadcasts. Give per-broadcast tracks their own cap per group + broadcast, sized (or configurable) for every reader the aggregator fans in, + not one page. Refuse requests beyond it with a typed error the reader + retries, rather than a parked track that reads as zero. Tier requests park + instead (`MAX_PARKED_REQUESTS`) because a collector treats a refusal as + final; per-broadcast readers must not. - **Sessions** (decided 2026-10-05). `sessions.json` is keyed by auth root and loses pruned roots the same way, so fold per-tier `Presence` into the totals (session counts) and serve per-root detail as a requested track, the same model as per-broadcast tracks. - **Retire the map tracks** (`publisher.json`, `subscriber.json`, - `sessions.json`, and their `.json.z` siblings) in the same release. + `sessions.json`, and their `.json.z` siblings) in the same release. Decide + while implementing whether totals and per-broadcast tracks keep `.json.z` + siblings. - Ship in the same breaking release as stats epochs, so consumers take one stats path and wire change, not two. -- Update the aggregator and `doc/concept/stats.md` with the change. The +- Update the aggregator, `doc/concept/stats.md`, and `demo/web/src/stats.ts` + (which reads the three maps) with the change. The aggregator merges totals across nodes, and merges one broadcast's track across nodes when a reader requests it, so a multi-node consumer serves the same format it reads. From 72ee4ca2df6b46f57b10f9cb8be2df1100895d82 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 5 Oct 2026 14:52:49 -0700 Subject: [PATCH 5/5] docs(quest): stats linger is Related to stats-split, and fix the lost-tail wording The linger does not gate the release; a zero linger is valid. A reader loses an epoch's tail only by missing every frame across the linger, and billing under-bills by it. Co-Authored-By: Claude Opus 5.5 --- quest/m0/broadcast-epoch/stats-epoch.md | 3 ++- quest/m0/broadcast-epoch/stats-split.md | 14 ++++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/quest/m0/broadcast-epoch/stats-epoch.md b/quest/m0/broadcast-epoch/stats-epoch.md index 8022dad69b..38300cc73b 100644 --- a/quest/m0/broadcast-epoch/stats-epoch.md +++ b/quest/m0/broadcast-epoch/stats-epoch.md @@ -17,7 +17,8 @@ a broadcast name or the group numbers a relay cached under it. counted from zero. Why: a producer never holds an idle group's state, so its memory is bounded by active and lingering groups, and readers see every reset as a new path rather than detecting one. The cost: a reader that - misses a group's last frame before the unannounce loses those increments. + misses every frame across the linger loses that epoch's tail; billing + under-bills by that tail, consistent with the 0-bill baseline. At depth 0 the single broadcast never unannounces, so its epoch still lasts the producer's life. - [#4739](https://github.com/moq-dev/moq/pull/4739)'s original commits are diff --git a/quest/m0/broadcast-epoch/stats-split.md b/quest/m0/broadcast-epoch/stats-split.md index 0f3b664381..d133da227f 100644 --- a/quest/m0/broadcast-epoch/stats-split.md +++ b/quest/m0/broadcast-epoch/stats-split.md @@ -29,13 +29,15 @@ Decided 2026-10-05 (planned from moq-dev/moq.pro#2202): the moment it has no entries (`rs/moq-stats/src/produce.rs`). Instead it stays announced for the stats linger (`quest/m0/stats-linger.md`, an m0 quest planned in [#4843](https://github.com/moq-dev/moq/pull/4843); list it - under Required once it lands) after its last entry ends, so a group that returns within the linger - continues its totals. After the linger it unannounces and drops its + under Related once it lands, not Required, so it does not gate the release) + after its last entry ends, so a group that returns within the linger + continues its totals. A zero linger is valid: a returning group then always + takes a new epoch. After the linger it unannounces and drops its totals; a return announces under a new [epoch](/quest/m0/broadcast-epoch/stats-epoch.md) - counted from zero. Totals are cumulative, so a missed middle frame costs - nothing, but a reader that misses a group's last frame before the - unannounce loses that epoch's tail; document that, and note that billing - relies on the aggregator's grace fold to cover it. Memory is bounded by active and + counted from zero. Totals are cumulative and a lingering group's frames all + repeat its final totals, so a reader that misses every frame across the + linger loses that epoch's tail; billing under-bills by that tail, + consistent with the 0-bill baseline. Document that. Memory is bounded by active and lingering groups. Test two groups sharing a tier, a group returning within the linger (same epoch, totals continue), and one returning after it (new epoch from zero).