Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions drafts/draft-lcurley-moq-cluster.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,7 @@ The expected update is a ROUTE_COST change, which is how a relay signals that it
# Path Selection {#selection}
A receiver resolving a SUBSCRIBE, FETCH, or track-status request consults only the most specific advertisements covering it: the longest prefix.
A refusal never falls through to a less specific tier.
Nor is it retried at another advertisement of the same prefix, except for the one re-resolution NO_CAPACITY permits below.

Within that tier, a receiver SHOULD prefer a HOP_PATH that contains no 0 entry over one that does, then the lowest ROUTE_COST, breaking ties toward the shorter HOP_PATH and then toward the most recently received.
This is advisory: a receiver MAY apply local policy, such as measured RTT, instead.
Expand Down
2 changes: 2 additions & 0 deletions drafts/draft-lcurley-moq-lite.md
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,7 @@ A SUBSCRIBE, FETCH, or TRACK request names a path, and the receiver resolves it
Only the most specific covering routes are consulted: those with the longest prefix.
A route strictly inside another's paths always ranks above it, and a broadcast's exact path is the most specific route there is, so a concrete announcement shadows every broader route covering it at any cost; routes over the same prefix form one tier.
The winning tier is the whole answer: a refusal from it never falls through to a less specific route, so a service refusing a path does not leak the request to a catch-all, and one unserved path costs one round trip rather than a walk down the candidates.
Nor is a refusal retried at another route of the same prefix, except for the one re-resolution NO_CAPACITY permits below.

Within the tier, cost and the tie-breaks of [Routing](#routing) order the routes.

Expand Down Expand Up @@ -1359,6 +1360,7 @@ The `Message Length` describes the payload size on the wire.
## moq-lite-07

- The subscriber FINs its Subscribe Stream after settling its tail; graceful session close waits for that FIN or reset.
- A refusal is not retried at another route of the same prefix either, apart from NO_CAPACITY's one re-resolution.
- Made TRACK_INFO Publisher Max Age optional, encoded as milliseconds plus one with zero meaning no limit.
- Added `Largest Group` and `Largest Frame` to SUBSCRIBE_OK: the publisher's largest position when it answers, which a subscriber takes as where the live feed is. A publisher MUST answer at once when the requested start is past it. Earlier versions carry no such position, so a subscriber takes the first frame instead.
- A path names one Broadcast whichever publisher serves it, and a publisher MUST NOT reuse a path for different content. A relay MAY move a subscription between any routes covering the path, continuing from the first frame the subscriber lacks instead of at a group boundary. Replaces the first-hop identity.
Expand Down
49 changes: 41 additions & 8 deletions js/net/src/origin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1539,7 +1539,7 @@ test("a refusal is terminal, never falling through to a broader route", async ()
origin.close();
});

test("a better route's refusal leaves the serving route in place", async () => {
test("a better route's refusal ends a served request", async () => {
const origin = new Producer();
const consumer = origin.consume();
const served = new BroadcastProducer();
Expand All @@ -1550,19 +1550,25 @@ test("a better route's refusal leaves the serving route in place", async () => {
const before = request.active.peek();
expect(before).toBeDefined();

// The narrower route is asked while the broad one keeps serving, then says no.
const narrow = origin.dynamic(Path.from("live"));
// The narrower route is asked while the broad one keeps serving, then says no. A
// costlier sibling at the same prefix could serve, but is never asked.
const narrow = origin.dynamic(Path.from("live"), { cost: 1n });
const sibling = origin.dynamic(Path.from("live"), { cost: 2n });
const siblingRequests = sibling.requested();
const siblingAsked = siblingRequests.next();
const { value: req } = await narrow.requested().next();
expect(request.active.peek()).toBe(before);
req?.reject(new Error("unserved"));
const err = new Error("unserved");
req?.reject(err);
await settle();

expect(request.active.peek()).toBe(before);
expect(before?.closed.peek()).toBeUndefined();
expect(request.closed.peek()).toBeUndefined();
expect(await request.closed).toBe(err);
expect(request.active.peek()).toBeUndefined();
expect(await Promise.race([siblingAsked.then(() => "asked"), settle().then(() => "idle")])).toBe("idle");

request.close();
expect(request.closed.peek()).toBeNull();
void siblingRequests.return?.();
sibling.close();
narrow.close();
disposeWide();
served.close();
Expand Down Expand Up @@ -1598,6 +1604,33 @@ test("a refusal from a superseded route does not end the request", async () => {
origin.close();
});

test("a refusal from a route a local broadcast superseded does not end the request", async () => {
const origin = new Producer();
const consumer = origin.consume();
const wide = origin.dynamic(Path.from("live"));
const wideRequests = wide.requested();

const request = consumer.request(Path.from("live/cam"));
const { value: stale } = await wideRequests.next();

// The exact local broadcast takes over before the broad route answers.
const local = publish(origin, Path.from("live/cam"));
await settle();
const active = request.active.peek();
expect(active).toBeDefined();

stale?.reject(new Error("unserved"));
await settle();
expect(request.closed.peek()).toBeUndefined();
expect(request.active.peek()).toBe(active);

request.close();
void wideRequests.return?.();
wide.close();
local.close();
origin.close();
});

test("close rejects queued requests with NoCapacity", async () => {
const origin = new Producer();
const handle = origin.dynamic(Path.from("live"));
Expand Down
39 changes: 13 additions & 26 deletions js/net/src/origin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,12 +165,9 @@ class CoveringRoot {
*
* `handles` holds the `closed` of each open {@link Requesting} on the path.
*
* A refusal is terminal. With nothing serving, the slot ends: every handle closes with the
* handler's error and the slot leaves the table, so the next request asks afresh. While
* another source still serves (a better route was asked and said no), the refuser joins
* `refused` and is skipped for as long as it stands (a reconnect is a fresh entry), so the
* current source carries on. A refusal never falls through to a broader prefix or another
* advertiser.
* A refusal is terminal, even while another source still serves: the slot ends, every handle
* closes with the handler's error, and the slot leaves the table, so the next request asks
* afresh. A refusal never falls through to a broader prefix or another advertiser.
*
* @internal
*/
Expand All @@ -181,7 +178,6 @@ export interface RequestSlot {
announced: number;
answer?: broadcast.Consumer;
readonly handles: Set<Once<Error | null>>;
readonly refused: Set<RouteEntry>;
readonly route: Signal<broadcast.Consumer | undefined>;
}

Expand Down Expand Up @@ -494,21 +490,16 @@ class OriginState {
}

/**
* `entry` refused `path` with `err`. A request still serving another source skips the
* refuser; one with nothing serving ends with `err`.
* `entry` refused `path` with `err`: the request ends with `err`, even while another
* source serves, since asking the next candidate would turn one refusal into a request
* per candidate.
*/
refuse(path: Path.Valid, entry: RouteEntry, err: Error): void {
const slot = this.requests.peek()?.get(path);
if (!slot) return;
// Only the route the request is waiting on speaks for it; a superseded one's answer is moot.
if (this.bestEntry(path, (candidate) => slot.refused.has(candidate)) !== entry) return;

const serving = slot.route.peek();
if (serving && serving.closed.peek() === undefined) {
slot.refused.add(entry);
slot.route.set(this.route(path, slot));
return;
}
// Only the route the request is waiting on speaks for it; one superseded by another
// route or a local broadcast has a moot answer.
if (this.bestEntry(path) !== entry || this.localWins(path, entry)) return;

this.requests.mutate((map) => {
if (map?.get(path) === slot) map.delete(path);
Expand Down Expand Up @@ -595,10 +586,10 @@ class OriginState {
* Materialization is lazy and cached per path: the first request under a route opens
* the providing session's subscription and repeats share it. A better route is made
* before the old one breaks: the current front keeps serving until the new route
* answers (then swaps) or refuses (then is skipped). A retracted route swaps at once.
* answers (then swaps) or refuses (then the request ends). A retracted route swaps at once.
*/
route(path: Path.Valid, slot: Pick<RequestSlot, "answer" | "refused">): broadcast.Consumer | undefined {
const entry = this.bestEntry(path, (candidate) => slot.refused.has(candidate));
route(path: Path.Valid, slot: Pick<RequestSlot, "answer">): broadcast.Consumer | undefined {
const entry = this.bestEntry(path);
const local = this.local.peek()?.get(path);
if (local && this.localWins(path, entry)) {
// Nothing reads a remote front the local broadcast replaced, so close its session subscription.
Expand Down Expand Up @@ -876,8 +867,6 @@ export class Producer implements Table {
entries.splice(index, 1);
if (entries.length === 0) routes?.delete(prefix);
});
// A retracted entry can never be picked again, so the refusals pinned to it are dead weight.
for (const slot of this.#state.requests.peek()?.values() ?? []) slot.refused.delete(entry);
server.close();
this.#state.rebuildOriginated();
this.#state.refreshPrefix(prefix);
Expand Down Expand Up @@ -1284,13 +1273,11 @@ export class Consumer {
// value as the baseline the next change is compared against, and never flushes to
// clear it, so a seeded route retracting to undefined would look like no change and
// notify nobody.
const refused = new Set<RouteEntry>();
const created: RequestSlot = {
blind: 0,
announced: 0,
handles: new Set(),
refused,
route: new Signal(this.#state.route(path, { refused })),
route: new Signal(this.#state.route(path, {})),
};
slot = created;
this.#state.requests.mutate((map) => {
Expand Down
2 changes: 1 addition & 1 deletion quest/m0/shared-fronts.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,4 @@ Public API: none. Wire: none.

- [Front deadlines](/quest/m1/front-deadline-index.md) - per-front cost per track
- [Front parking](/quest/m1/origin-front-parks.md) - also changes what mints a front
- [Wildcard](/quest/m0/wildcard/README.md) - its line edits `model/origin.rs` resolution, and its [refusal child](/quest/m0/wildcard/refusal-final.md) makes a standing refusal end the front; land after it or rebase onto it
- [Wildcard](/quest/m0/wildcard/README.md) - its line edits `model/origin.rs` resolution; land after it or rebase onto it
1 change: 0 additions & 1 deletion quest/m0/wildcard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,6 @@ distinguish recording generations reads the catalog's archive entry
## Required

- [Transcoders start at group boundaries](/quest/m0/wildcard/transcode-group-start.md) - two claim workers at one path are one broadcast, so a relay never splices them mid-group
- [A standing refusal ends the front](/quest/m0/wildcard/refusal-final.md) - a refusal from the winning route ends the request instead of re-selecting a sibling or a shorter prefix

## Related

Expand Down
40 changes: 0 additions & 40 deletions quest/m0/wildcard/refusal-final.md

This file was deleted.

5 changes: 2 additions & 3 deletions quest/m1/cluster-routing/selection.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,8 @@ Recorded in the 2026-10-05 audit: part of this is already built. The
wildcard line's `route_order` hash is keyed on the requested path, the HRW
spread below, and #4741 made a concrete path one source with mid-group
resume. Once [Wildcard](/quest/m0/wildcard/README.md) (#4403) lands, cut this
quest down to what remains: terminal refusal (see its
[refusal child](/quest/m0/wildcard/refusal-final.md)), the HRW split test,
the same-path failover test, and upstream-link spread.
quest down to what remains: the HRW split test, the same-path failover
test, and upstream-link spread. Terminal refusal already landed.

Candidate mechanics:

Expand Down
Loading
Loading