diff --git a/js/net/bench/broadcasts.ts b/js/net/bench/broadcasts.ts index b912e5dfe0..bb305c0667 100644 --- a/js/net/bench/broadcasts.ts +++ b/js/net/bench/broadcasts.ts @@ -1,7 +1,9 @@ /** Sweep observers against routes for one route update, and against tracks for one demand edge. */ import { Producer as BroadcastProducer } from "../src/broadcast.ts"; +import { BroadcastCache } from "../src/consume.ts"; import * as Epoch from "../src/epoch.ts"; +import { Route } from "../src/hop.ts"; import { Producer } from "../src/origin.ts"; import * as Path from "../src/path.ts"; @@ -120,3 +122,99 @@ for (const pinned of [false, true]) { } } } + +// Only a touched announcement and path should cost work, regardless of unrelated routes or consumes. +console.log("subscriber,announcements,consumers,update_and_consume_us"); +for (const routeCount of routeCounts) { + for (const consumerCount of [8, 32, 128]) { + const cache = new BroadcastCache(); + const owners = Array.from({ length: routeCount }, () => ({})); + for (let i = 0; i < routeCount; i++) cache.announce(Path.from(`pool/${i}`), owners[i], Route.default); + const sources = Array.from({ length: consumerCount }, () => new BroadcastProducer()); + const held = sources.map((source, i) => { + const path = Path.from(`pool/${i % routeCount}/job/${i}`); + return cache.insert(path, cache.instance(path), source.consume()); + }); + const touched = Path.from("pool/0/job/0"); + const start = performance.now(); + for (let i = 0; i < 4096; i++) { + cache.announce(Path.from("pool/0"), owners[0], { ...Route.default, cost: BigInt(i) }); + const shared = cache.get(touched, cache.instance(touched)); + if (!shared || shared.closed !== held[0].closed) throw new Error("update replaced the source"); + shared.close(); + } + console.log(`cache,${routeCount},${consumerCount},${(((performance.now() - start) * 1000) / 4096).toFixed(2)}`); + for (const consumer of held) consumer.close(); + for (const source of sources) source.close(); + } +} + +// Switching a more-specific route on and off must recover all held fallback consumers. +console.log("alternation,announcements,consumers,select_us,duplicates"); +for (const routeCount of routeCounts) { + for (const consumerCount of [1, 8, 32]) { + const cache = new BroadcastCache(); + const owners = Array.from({ length: routeCount }, () => ({})); + for (let i = 0; i < routeCount; i++) cache.announce(Path.from(`pool/${i}`), owners[i], Route.default); + const sources = Array.from({ length: consumerCount }, () => new BroadcastProducer()); + const paths = sources.map((_, i) => Path.from(`pool/0/job/${i}`)); + const held = sources.map((source, i) => cache.insert(paths[i], cache.instance(paths[i]), source.consume())); + const specific = {}; + const prefix = Path.from("pool/0/job"); + const epoch = Epoch.mint(); + let duplicates = 0; + const start = performance.now(); + for (let round = 0; round < 256; round++) { + if (round % 2 === 0) cache.announce(prefix, specific, { ...Route.default, epoch }); + else cache.withdraw(prefix, specific); + for (let i = 0; i < consumerCount; i++) { + const instance = cache.instance(paths[i]); + const shared = cache.get(paths[i], instance); + if (shared) shared.close(); + else { + if (round > 0) duplicates++; + held.push(cache.insert(paths[i], instance, sources[i].consume())); + } + } + } + console.log( + `alternation,${routeCount},${consumerCount},${(((performance.now() - start) * 1000) / 256).toFixed(2)},${duplicates}`, + ); + for (const consumer of held) consumer.close(); + for (const source of sources) source.close(); + } +} + +// Blind invalidation touches only the held paths covered by the new announcement. +console.log("blind,announcements,consumers,invalidate_us,stale"); +for (const routeCount of routeCounts) { + for (const consumerCount of [1, 8, 32]) { + const cache = new BroadcastCache(); + for (let i = 0; i < routeCount; i++) cache.announce(Path.from(`other/${i}`), {}, Route.default); + const source = new BroadcastProducer(); + const keeper = source.consume(); + const paths = Array.from({ length: consumerCount }, (_, i) => Path.from(`pool/job/${i}`)); + const prefix = Path.from("pool"); + const owner = {}; + let elapsed = 0; + let stale = 0; + for (let round = 0; round < 256; round++) { + const held = paths.map((path) => cache.insert(path, cache.instance(path), source.consume())); + const start = performance.now(); + cache.announce(prefix, owner, Route.default); + cache.withdraw(prefix, owner); + elapsed += performance.now() - start; + for (const path of paths) { + const shared = cache.get(path, cache.instance(path)); + if (shared) { + stale++; + shared.close(); + } + } + for (const consumer of held) consumer.close(); + } + console.log(`blind,${routeCount},${consumerCount},${((elapsed * 1000) / 256).toFixed(2)},${stale}`); + keeper.close(); + source.close(); + } +} diff --git a/js/net/src/consume.test.ts b/js/net/src/consume.test.ts new file mode 100644 index 0000000000..8d84a1ca54 --- /dev/null +++ b/js/net/src/consume.test.ts @@ -0,0 +1,101 @@ +import { expect, test } from "bun:test"; +import { Producer } from "./broadcast.ts"; +import { BroadcastCache } from "./consume.ts"; +import * as Epoch from "./epoch.ts"; +import { Route } from "./hop.ts"; +import * as Path from "./path.ts"; + +test("cache identity follows the serving announcement, preserving held tracks", async () => { + const cache = new BroadcastCache(); + const pool = Path.from("pool"); + const job = Path.from("pool/job"); + const broad = {}; + const specific = {}; + const epoch = Epoch.mint(); + cache.announce(pool, broad, { ...Route.default, epoch }); + const source = new Producer(); + const track = source.createTrack("video"); + const held = cache.insert(job, cache.instance(job), source.consume()); + const ordered = held.track("video").subscribe().ordered(); + cache.announce(job, specific, { ...Route.default, epoch }); + const same = cache.get(job, cache.instance(job)); + expect(same?.closed).toBe(held.closed); + + cache.announce(job, specific, Route.default, true); + expect(cache.instance(job).route.epoch).toBeUndefined(); + expect(cache.get(job, cache.instance(job))).toBeUndefined(); + const replacement = new Producer(); + const fresh = cache.insert(job, cache.instance(job), replacement.consume()); + cache.withdraw(pool, broad); + const shared = cache.get(job, cache.instance(job)); + expect(shared?.closed).toBe(fresh.closed); + // Cache replacement never transfers or closes the track an old handle is reading. + track.appendGroup().close(); + expect((await ordered.nextGroup())?.sequence).toBe(0); + cache.withdraw(job, specific); + expect(cache.get(job, cache.instance(job))).toBeUndefined(); + for (const consumer of [held, same, fresh, shared]) consumer?.close(); + ordered.close(); + source.close(); + replacement.close(); +}); + +test("withdrawing one interest keeps another interest's explicit identity", () => { + const cache = new BroadcastCache(); + const path = Path.from("room"); + const first = {}; + const second = {}; + const route = { ...Route.default, epoch: Epoch.mint() }; + cache.announce(path, first, route); + cache.announce(path, second, route); + const source = new Producer(); + const held = cache.insert(path, cache.instance(path), source.consume()); + cache.withdraw(path, first); + const same = cache.get(path, cache.instance(path)); + expect(same?.closed).toBe(held.closed); + cache.withdraw(path, second); + expect(cache.get(path, cache.instance(path))).toBeUndefined(); + held.close(); + same?.close(); + source.close(); +}); + +test("an announcement lifecycle retires a blind cache without disturbing unrelated paths", () => { + const cache = new BroadcastCache(); + const path = Path.from("pool/job"); + const other = Path.from("other/job"); + const source = new Producer(); + const unrelated = new Producer(); + const held = cache.insert(path, cache.instance(path), source.consume()); + const otherHeld = cache.insert(other, cache.instance(other), unrelated.consume()); + const owner = {}; + cache.announce(Path.from("pool"), owner, Route.default); + cache.withdraw(Path.from("pool"), owner); + expect(cache.get(path, cache.instance(path))).toBeUndefined(); + const same = cache.get(other, cache.instance(other)); + expect(same?.closed).toBe(otherHeld.closed); + expect(held.closed.peek()).toBeUndefined(); + for (const consumer of [held, otherHeld, same]) consumer?.close(); + source.close(); + unrelated.close(); +}); + +test.each([false, true])("route alternation reuses every held instance (epoch: %s)", (explicit) => { + const cache = new BroadcastCache(); + const path = Path.from("pool/job"); + const broad = Path.from("pool"); + const ownerA = {}; + const ownerB = {}; + cache.announce(broad, ownerA, { ...Route.default, epoch: explicit ? Epoch.mint() : undefined }); + const a = new Producer(); + const heldA = cache.insert(path, cache.instance(path), a.consume()); + cache.announce(path, ownerB, { ...Route.default, epoch: explicit ? Epoch.mint() : undefined }); + const b = new Producer(); + const heldB = cache.insert(path, cache.instance(path), b.consume()); + cache.withdraw(path, ownerB); + const again = cache.get(path, cache.instance(path)); + expect(again?.closed).toBe(heldA.closed); + for (const consumer of [heldA, heldB, again]) consumer?.close(); + a.close(); + b.close(); +}); diff --git a/js/net/src/consume.ts b/js/net/src/consume.ts index 073f91058d..c6e4a392d4 100644 --- a/js/net/src/consume.ts +++ b/js/net/src/consume.ts @@ -1,68 +1,102 @@ import type * as broadcast from "./broadcast.ts"; -import * as Path from "./path.ts"; +import type * as Epoch from "./epoch.ts"; +import type { Route } from "./hop.ts"; +import { compareRouteCandidates, coveringPrefixes } from "./internal.ts"; +import type * as Path from "./path.ts"; +import type { Instance } from "./wire.ts"; -/** - * Per-path dedup cache for consumed broadcasts, shared by the moq-lite and moq-ietf - * subscribers. - * - * Resolving the same path must not mint a fresh subscription per call: repeat requests - * for the same path (e.g. several renditions referencing one `broadcast: "./source"`) should - * share a single upstream subscription. This mirrors the Rust `origin::Consumer` weak-cache: - * a still-live path resolves to a shared {@link broadcast.Consumer.clone}, a closed one is - * re-consumed on the next request. Each handle is reference-counted, so the shared broadcast - * closes once every caller has closed its handle. - * - * @internal - */ +class Blind implements Instance { + readonly identity = {}; + readonly route = {}; +} + +/** Consumed broadcasts shared only within the selected serving instance. @internal */ export class BroadcastCache { - // The base handle per path; callers get reference-counted clones of it. - #cache = new Map(); + #cache = new Map>(); + #announced = new Map>(); + #blind = new Map(); + #blindPrefixes = new Map>(); + + /** Record this interest's live advertisement, preserving identity across route updates. */ + announce(path: Path.Valid, owner: object, route: Route, restart = false): void { + // Only unannounced consumers covered by this advertisement lose their blind identity. + for (const covered of this.#blindPrefixes.get(path) ?? []) this.#forgetBlind(covered); + let entries = this.#announced.get(path); + if (!entries) { + entries = new Map(); + this.#announced.set(path, entries); + } + const identity = (!restart && entries.get(owner)?.identity) || {}; + entries.set(owner, { identity, route }); + } + + /** Withdraw this interest's advertisement without disturbing another interest's routes. */ + withdraw(path: Path.Valid, owner: object): void { + const entries = this.#announced.get(path); + entries?.delete(owner); + if (entries?.size === 0) this.#announced.delete(path); + } - /** A shared handle to the live broadcast cached for `path`, or `undefined` on a miss. */ - get(path: Path.Valid): broadcast.Consumer | undefined { - const base = this.#cache.get(path); - if (base && base.closed.peek() === undefined) return base.clone(); - return undefined; + /** Resolve the same most-specific-prefix and route preference as the origin. */ + instance(path: Path.Valid): Instance { + for (const prefix of coveringPrefixes(path)) { + const entries = this.#announced.get(prefix); + if (!entries) continue; + let best: { identity: object; route: Route } | undefined; + for (const entry of entries.values()) { + if (!best || compareRouteCandidates(path, entry.route, best.route) < 0) best = entry; + } + if (best) return best; + } + return this.#blind.get(path) ?? new Blind(); } - /** - * Cache `consumer` as the base handle for `path` (evicting it once it closes) and return it. - * Call on a {@link get} miss, after wiring up the fresh consumer's subscribe loop. - */ - insert(path: Path.Valid, consumer: broadcast.Consumer): broadcast.Consumer { - this.#cache.set(path, consumer); + /** Clone the live cached consumer only when it serves the selected instance. */ + get(path: Path.Valid, instance: Instance): broadcast.Consumer | undefined { + const cached = this.#cache.get(path)?.get(instance.route.epoch ?? instance.identity); + return cached?.closed.peek() === undefined ? cached?.clone() : undefined; + } - // Drop the entry once the broadcast closes (every handle released), so the next request - // re-consumes rather than cloning a dead handle. Guard against a newer entry for the path. + /** Cache each live instance so a temporary route winner never displaces a held fallback. */ + insert(path: Path.Valid, instance: Instance, consumer: broadcast.Consumer): broadcast.Consumer { + let entries = this.#cache.get(path); + if (!entries) { + entries = new Map(); + this.#cache.set(path, entries); + } + const key = instance.route.epoch ?? instance.identity; + entries.set(key, consumer); + if (instance instanceof Blind) { + this.#blind.set(path, instance); + for (const prefix of coveringPrefixes(path)) { + let paths = this.#blindPrefixes.get(prefix); + if (!paths) { + paths = new Set(); + this.#blindPrefixes.set(prefix, paths); + } + paths.add(path); + } + } void consumer.closed.then(() => { - if (this.#cache.get(path) === consumer) this.#cache.delete(path); + if (entries.get(key) !== consumer) return; + entries.delete(key); + if (entries.size === 0 && this.#cache.get(path) === entries) this.#cache.delete(path); + if (this.#blind.get(path) === instance) this.#forgetBlind(path); }); - return consumer; } - /** - * Stop sharing the broadcasts cached at or beneath `path`, so the next request subscribes fresh. - * - * Call when the advertisement for `path` goes away or is replaced. It covered every path - * beneath it, so those go too, even one a more specific advertisement also covers: that costs - * a duplicate subscription, never a stale one. A handle only leaves the cache on its own - * once *every* holder has closed it, so one holder outliving the publisher (a second - * watcher, or a caller consuming the path directly) would otherwise keep the dead - * generation's cached tracks alive and hand them to whoever consumes the path next. - * Existing handles are left alone: they belong to their holders, and the wire resets - * whatever they still have open. - * - * Eviction is unconditional, which costs a dedup miss when two announcement streams watch - * one path: the second stream's retraction can arrive after the first has already seen the - * replacement, dropping the fresh entry so the next request subscribes again instead of - * sharing. Telling that stale retraction from a live one needs a generation id on the - * advertisement (moq-lite's `Epoch`, not yet on the wire), so until then this errs toward a - * duplicate subscription rather than risk handing out a dead one. - */ - evict(path: Path.Valid): void { - for (const cached of this.#cache.keys()) { - if (Path.hasPrefix(path, cached)) this.#cache.delete(cached); + #forgetBlind(path: Path.Valid): void { + const blind = this.#blind.get(path); + if (!blind) return; + this.#blind.delete(path); + const entries = this.#cache.get(path); + entries?.delete(blind.identity); + if (entries?.size === 0) this.#cache.delete(path); + for (const prefix of coveringPrefixes(path)) { + const paths = this.#blindPrefixes.get(prefix); + paths?.delete(path); + if (paths?.size === 0) this.#blindPrefixes.delete(prefix); } } } diff --git a/js/net/src/ietf/subscriber.test.ts b/js/net/src/ietf/subscriber.test.ts index 537cf47702..551a5dd6c2 100644 --- a/js/net/src/ietf/subscriber.test.ts +++ b/js/net/src/ietf/subscriber.test.ts @@ -1709,3 +1709,45 @@ test("an object datagram is a datagram group", async () => { ).toBeInstanceOf(ProtocolViolation); track.close(); }); + +test("a more specific epochless announcement replaces the consumed source", async () => { + const pair = createMockTransportPair(ALPN.DRAFT_19); + const subscriber = new Subscriber({ session: new NativeSession(pair.server, VERSION, true) }); + const announced = subscriber.announced(); + await acceptSubscribeNamespace(pair.client); + const pool = Path.from("pool"); + const job = Path.from("pool/job"); + const broadStream = await Stream.open(pair.server, { version: VERSION }); + const broad = subscriber.runPublishNamespace( + new PublishNamespace({ requestId: 0n, trackNamespace: pool }), + broadStream, + ); + await announced.next(); + const broadPeer = await nextStream(pair.client); + if (!broadPeer) throw new Error("no broad publish stream"); + await broadPeer.reader.u53(); + await RequestOk.decode(broadPeer.reader, VERSION); + const held = subscriber.consume(job); + const specificStream = await Stream.open(pair.server, { version: VERSION }); + const specific = subscriber.runPublishNamespace( + new PublishNamespace({ requestId: 2n, trackNamespace: job }), + specificStream, + ); + await announced.next(); + const specificPeer = await nextStream(pair.client); + if (!specificPeer) throw new Error("no specific publish stream"); + await specificPeer.reader.u53(); + await RequestOk.decode(specificPeer.reader, VERSION); + const fresh = subscriber.consume(job); + expect(fresh.closed).not.toBe(held.closed); + expect(held.closed.peek()).toBeUndefined(); + specificPeer.close(); + await specific; + await announced.next(); + const fallback = subscriber.consume(job); + expect(fallback.closed).toBe(held.closed); + broadPeer.close(); + await broad; + for (const consumer of [held, fresh, fallback]) consumer.close(); + announced.close(); +}); diff --git a/js/net/src/ietf/subscriber.ts b/js/net/src/ietf/subscriber.ts index 4af30a1b72..c7ee5aaa8b 100644 --- a/js/net/src/ietf/subscriber.ts +++ b/js/net/src/ietf/subscriber.ts @@ -313,7 +313,9 @@ export class Subscriber { if (this.#goingAway()) this.#updateAnnounce(path, existing.route); return; } - this.#announced.set(path, { count: 1, route }); + const entry = { count: 1, route }; + this.#announced.set(path, entry); + this.#consumes.announce(path, entry, route); console.debug(`announced: broadcast=${path} active=true`); for (const [consumer, filter] of this.#announcedConsumers) { @@ -334,6 +336,7 @@ export class Subscriber { const existing = this.#announced.get(path); if (existing === undefined || routesEqual(existing.route, route)) return; existing.route = route; + this.#consumes.announce(path, existing, route); console.debug(`announced: broadcast=${path} rerouted`); for (const [consumer, filter] of this.#announcedConsumers) { if (!sees(filter, path)) continue; @@ -357,7 +360,7 @@ export class Subscriber { // The path is gone, so stop sharing its broadcast: a holder outliving the publisher // would otherwise hand the dead generation to whoever consumes the path next. - this.#consumes.evict(path); + this.#consumes.withdraw(path, existing); console.debug(`announced: broadcast=${path} active=false`); for (const [consumer, filter] of this.#announcedConsumers) { @@ -564,12 +567,13 @@ export class Subscriber { /** * Consumes a broadcast from the connection. * - * Deduplicated per path: repeat calls for the same still-live path share one reference-counted + * Deduplicated per serving instance: repeat calls for the same path share one reference-counted * broadcast (and one upstream subscription). The shared broadcast closes once every caller has * closed its handle, so callers close normally. */ consume(path: Path.Valid): broadcast.Consumer { - return this.#consumes.get(path) ?? this.#consumes.insert(path, this.#createConsume(path)); + const instance = this.#consumes.instance(path); + return this.#consumes.get(path, instance) ?? this.#consumes.insert(path, instance, this.#createConsume(path)); } #createConsume(path: Path.Valid): broadcast.Consumer { diff --git a/js/net/src/internal.ts b/js/net/src/internal.ts index 0fa1f7f737..4700d3f862 100644 --- a/js/net/src/internal.ts +++ b/js/net/src/internal.ts @@ -9,7 +9,7 @@ import type { Dispose, Getter } from "@moq/signals"; import type { Consumer as BroadcastConsumer, Producer as BroadcastProducer } from "./broadcast.ts"; import type * as Epoch from "./epoch.ts"; import type { Frame, Consumer as GroupConsumer, Producer as GroupProducer } from "./group.ts"; -import type { Route } from "./hop.ts"; +import { isAnonymous, type Route } from "./hop.ts"; import * as Path from "./path.ts"; import type { Timestamp } from "./time.ts"; import type { Groups, Producer, Request, Subscriber } from "./track.ts"; @@ -259,3 +259,32 @@ export function spreadHash(path: string, hops: readonly bigint[]): bigint { } return hash; } + +/** Covering prefixes, most specific first, including the empty root. */ +export function* coveringPrefixes(path: Path.Valid): Generator { + for (;;) { + yield path; + if (path === "") return; + const slash = path.lastIndexOf("/"); + path = (slash < 0 ? "" : path.slice(0, slash)) as Path.Valid; + } +} + +/** Prefer newer epochs, identified publishers, then lower static cost. */ +export function compareRoutes(a: Route, b: Route): number { + if (a.epoch !== b.epoch) { + if (a.epoch === undefined) return 1; + if (b.epoch === undefined) return -1; + return a.epoch > b.epoch ? -1 : 1; + } + return Number(isAnonymous(a)) - Number(isAnonymous(b)) || (a.cost < b.cost ? -1 : a.cost > b.cost ? 1 : 0); +} + +/** Order same-prefix routes by preference, hop count, and the origin's stable spread hash. */ +export function compareRouteCandidates(path: Path.Valid, a: Route, b: Route): number { + const order = compareRoutes(a, b) || a.hops.length - b.hops.length; + if (order !== 0) return order; + const ha = spreadHash(path, a.hops); + const hb = spreadHash(path, b.hops); + return ha < hb ? -1 : ha > hb ? 1 : 0; +} diff --git a/js/net/src/lite/subscriber.test.ts b/js/net/src/lite/subscriber.test.ts index 148133462f..dbc56c3220 100644 --- a/js/net/src/lite/subscriber.test.ts +++ b/js/net/src/lite/subscriber.test.ts @@ -6,13 +6,14 @@ import * as Epoch from "../epoch.ts"; import { error, fromTransport, reason, StreamCode, StreamError } from "../error.ts"; import { HopSchema, isAnonymous, MAX_HOPS, Route, UNKNOWN_HOP } from "../hop.ts"; import * as Path from "../path.ts"; -import { type Reader, Writer } from "../stream.ts"; +import { Reader, Writer } from "../stream.ts"; import * as Time from "../time.ts"; import { type AnnounceBroadcast, AnnounceInit, AnnounceOk, encodeAnnounceBroadcast } from "./announce.ts"; import { Group } from "./group.ts"; import { Probe } from "./probe.ts"; +import { StreamId } from "./stream.ts"; import { SUBSCRIBE_SETUP_TIMEOUT_MS, Subscriber } from "./subscriber.ts"; -import { TrackInfo } from "./track.ts"; +import { Track, TrackInfo } from "./track.ts"; import { Version } from "./version.ts"; test("closing the subscriber suppresses probe stream warnings", async () => { @@ -873,7 +874,7 @@ function fakeSession(park: number[] = [], stall: number[] = []) { const written: Uint8Array[] = []; const writable = new WritableStream({ write: (chunk) => { - written.push(chunk); + written.push(new Uint8Array(chunk)); return stalled ? new Promise(() => {}) : undefined; }, close: () => onFinish(), @@ -1279,3 +1280,87 @@ test("the last reader leaving mid-response cancels the fetch", async () => { subscriber.close(); }); + +// Drive discovery and inspect the TRACK request before answering it. Waiting for the +// reader means the whole request has reached the wire, without a clock-based settle. +test("consumes resolve the serving prefix and pin its epoch", async () => { + const version = Version.DRAFT_07; + const { quic, streams } = fakeSession(); + const subscriber = new Subscriber(quic, version, HopSchema.parse(1n)); + const announced = subscriber.announced(); + await drainUntil(() => streams.length === 1); + const peer = new Writer( + new WritableStream({ + write: (chunk) => streams[0].inbound.enqueue(new Uint8Array(chunk)), + }), + version, + ); + await new AnnounceOk(PEER, 0).encode(peer, version); + const pool = Path.from("pool"); + const job = Path.from("pool/job"); + const epoch = Epoch.mint(); + await encodeAnnounceBroadcast(peer, { status: "active", suffix: pool, epoch, hops: [PUBLISHER_A] }, version); + await announced.next(); + const held = subscriber.consume(job); + const info = held + .track("video") + .info() + .catch(() => {}); + await drainUntil(() => streams.length === 2); + await streams[1].reading; + const reader = new Reader(undefined, new Uint8Array(Buffer.concat(streams[1].written)), version); + expect(await reader.u53()).toBe(StreamId.Track); + expect((await Track.decode(reader, version)).epoch).toBe(epoch); + + // No epoch on the more specific source must shadow the covering source's epoch. + await encodeAnnounceBroadcast(peer, { status: "active", suffix: job, hops: [PUBLISHER_B] }, version); + await announced.next(); + const fresh = subscriber.consume(job); + expect(fresh.closed).not.toBe(held.closed); + expect(held.closed.peek()).toBeUndefined(); + const freshInfo = fresh + .track("video") + .info() + .catch(() => {}); + await drainUntil(() => streams.length === 3); + await streams[2].reading; + const freshReader = new Reader(undefined, new Uint8Array(Buffer.concat(streams[2].written)), version); + await freshReader.u53(); + expect((await Track.decode(freshReader, version)).epoch).toBeUndefined(); + + await encodeAnnounceBroadcast(peer, { status: "endedId", id: 1n }, version); + await announced.next(); + const fallback = subscriber.consume(job); + expect(fallback.closed).toBe(held.closed); + expect(subscriber.consume(pool).closed).toBe(subscriber.consume(pool).closed); + fresh.close(); + held.close(); + fallback.close(); + announced.close(); + subscriber.close(); + await Promise.all([info, freshInfo]); +}); + +test.each([Version.DRAFT_06, Version.DRAFT_07])( + "a more specific start replaces an existing consume on %s", + async (version) => { + const { subscriber, send, settle } = announceHarness(version); + const announced = subscriber.announced(); + await settle(); + await send((w) => new AnnounceOk(PEER, 0).encode(w, version)); + const pool = Path.from("pool"); + const job = Path.from("pool/job"); + await send((w) => encodeAnnounceBroadcast(w, { status: "active", suffix: pool, hops: [PUBLISHER_A] }, version)); + await announced.next(); + const held = subscriber.consume(job); + await send((w) => encodeAnnounceBroadcast(w, { status: "active", suffix: job, hops: [PUBLISHER_B] }, version)); + await announced.next(); + const fresh = subscriber.consume(job); + expect(fresh.closed).not.toBe(held.closed); + expect(held.closed.peek()).toBeUndefined(); + fresh.close(); + held.close(); + announced.close(); + subscriber.close(); + }, +); diff --git a/js/net/src/lite/subscriber.ts b/js/net/src/lite/subscriber.ts index 5b4b5704a7..5960c76161 100644 --- a/js/net/src/lite/subscriber.ts +++ b/js/net/src/lite/subscriber.ts @@ -170,10 +170,6 @@ export class Subscriber { #consumes = new BroadcastCache(); #consumeNext = 0; - // The epoch each live advertisement named, by path. A consumed broadcast captures it - // once and asks for it on every request, so a later epoch never feeds an older handle. - #epochs = new Map(); - // Dedup in-flight one-shot fetches, keyed by [consume, broadcast, epoch, track, sequence]. // Concurrent (or repeat, while still open) fetchGroup() calls for the same group of one consume // share one FETCH stream and each get an independent mirror; the entry is evicted once the @@ -288,6 +284,21 @@ export class Subscriber { return; } + // Every advertisement the peer currently has live, keyed by suffix (at most one + // per path is current, and every announce on this stream shares `prefix`). + // + // An advertisement skipped locally as a reflected loop is recorded with + // `live: false`: the peer numbered it and will retract it regardless of what we + // made of it, so its path is not free. Dropping it from the map instead would let + // a later announce take the path, and the skipped one's `endedId` would then + // retract that one's state. + type Advertisement = { + live: boolean; + route: Route; + captures: Path.Pattern[] | undefined; + }; + const advertised = new Map(); + let stopDrain: Dispose | undefined; try { // Send the announce interest. @@ -306,21 +317,6 @@ export class Subscriber { responderOrigin = ok.hop; } - // Every advertisement the peer currently has live, keyed by suffix (at most one - // per path is current, and every announce on this stream shares `prefix`). - // - // An advertisement skipped locally as a reflected loop is recorded with - // `live: false`: the peer numbered it and will retract it regardless of what we - // made of it, so its path is not free. Dropping it from the map instead would let - // a later announce take the path, and the skipped one's `endedId` would then - // retract that one's state. - type Advertisement = { - live: boolean; - route: Route; - captures: Path.Pattern[] | undefined; - }; - const advertised = new Map(); - switch (this.version) { case Version.DRAFT_01: case Version.DRAFT_02: { @@ -342,6 +338,7 @@ export class Subscriber { const captures = scopeCaptures(scope, path); advertised.set(path, { live, route, captures }); if (!live) continue; + this.#consumes.announce(path, announced, route); console.debug(`announced: broadcast=${path} active=true`); announced.append({ prefix: path, captures, kind: "start", route }); } @@ -363,6 +360,7 @@ export class Subscriber { const route = { ...ad.route, cost: DRAIN_COST }; if (routesEqual(ad.route, route)) continue; advertised.set(path, { ...ad, route }); + this.#consumes.announce(path, announced, route); announced.append({ prefix: path, captures: ad.captures, kind: "update", route }); } }; @@ -455,16 +453,15 @@ export class Subscriber { throw new ProtocolViolation(`duplicate announce for ${path}`); } - // Retract the path: forget the advertisement, drop the shared consume entry so a - // later announce subscribes fresh rather than cloning the dead generation's tracks, - // and tell the consumer. A no-op for an advertisement never surfaced, which is + // Retract this interest's advertisement so new consumes resolve the remaining + // serving instance, and tell the consumer. A no-op for an advertisement never surfaced, which is // what an id retiring a skipped announce resolves to. const retract = () => { const previous = advertised.get(path); advertised.delete(path); - this.#epochs.delete(path); + this.#consumes.withdraw(path, announced); if (!previous?.live) return; - this.#consumes.evict(path); + console.debug(`announced: broadcast=${path} active=false`); announced.append({ prefix: path, @@ -530,10 +527,8 @@ export class Subscriber { // the next consume subscribes fresh, while the handles already out keep theirs. const previous = advertised.get(path); if (restart && previous?.live) { - this.#consumes.evict(path); advertised.set(path, { live: true, route, captures }); - if (epoch) this.#epochs.set(path, epoch); - else this.#epochs.delete(path); + this.#consumes.announce(path, announced, route, true); console.debug(`announced: broadcast=${path} restart=true epoch=${epoch}`); announced.append({ prefix: path, captures, kind: "restart", route }); continue; @@ -543,9 +538,9 @@ export class Subscriber { // explicit ANNOUNCE_UPDATE, or (lite-05) a duplicate ANNOUNCE. It updates the // route in place, so a forwarder re-prices without retracting. if (previous?.live) { - // Even from another publisher: the path still names the same broadcast, so - // the shared consume stays. + // An update preserves the advertisement identity, including on epochless drafts. advertised.set(path, { live: true, route, captures }); + this.#consumes.announce(path, announced, route); console.debug(`announced: broadcast=${path} rerouted`); if (!routesEqual(previous.route, route)) { announced.append({ prefix: path, captures, kind: "update", route }); @@ -554,8 +549,7 @@ export class Subscriber { } advertised.set(path, { live: true, route, captures }); - if (epoch) this.#epochs.set(path, epoch); - else this.#epochs.delete(path); + this.#consumes.announce(path, announced, route); console.debug(`announced: broadcast=${path} active=true epoch=${epoch}`); announced.append({ prefix: path, captures, kind: "start", route }); @@ -577,7 +571,8 @@ export class Subscriber { this.#quic.close({ closeCode: PROTOCOL_VIOLATION_CODE, reason: closeReason(reason(e)) }); } } finally { - // Releases this interest's routes on a session that never drains. + // Release only this interest's routes. Existing consumers remain pinned. + for (const path of advertised.keys()) this.#consumes.withdraw(path, announced); stopDrain?.(); } } @@ -585,7 +580,7 @@ export class Subscriber { /** * Consumes a broadcast from the connection. * - * Deduplicated per path: repeat calls for the same still-live path share one reference-counted + * Deduplicated per serving instance: repeat calls for the same path share one reference-counted * broadcast (and one upstream subscription). The shared broadcast closes once every caller has * closed its handle, so callers close normally. * @@ -593,14 +588,17 @@ export class Subscriber { * @returns A Broadcast instance */ consume(path: Path.Valid): broadcast.Consumer { - return this.#consumes.get(path) ?? this.#consumes.insert(path, this.#createConsume(path)); + const instance = this.#consumes.instance(path); + return ( + this.#consumes.get(path, instance) ?? + this.#consumes.insert(path, instance, this.#createConsume(path, instance.route.epoch)) + ); } - #createConsume(path: Path.Valid): broadcast.Consumer { + #createConsume(path: Path.Valid, epoch?: Epoch.Valid): broadcast.Consumer { // A consumed broadcast resolves info() and fetchGroup() over the wire by reaching // back into this Subscriber (see ConsumeBroadcast below), rather than the wire // installing callbacks on the broadcast. - const epoch = this.#epochs.get(path); const consumer = new ConsumeBroadcast(this, path, { epoch, id: this.#consumeNext++ }); void (async () => { diff --git a/js/net/src/origin.ts b/js/net/src/origin.ts index f67bd9fdd5..8b1ac5fc62 100644 --- a/js/net/src/origin.ts +++ b/js/net/src/origin.ts @@ -15,8 +15,18 @@ import * as announce from "./announced.ts"; import * as broadcast from "./broadcast.ts"; import type * as Epoch from "./epoch.ts"; import { StreamCode, StreamError } from "./error.ts"; -import { isAnonymous, Route, routesEqual } from "./hop.ts"; -import { hiddenBelow, hooks, scopeCaptures, scopeHead, scopeOverlaps, spreadHash } from "./internal.ts"; +import { Route, routesEqual } from "./hop.ts"; +import { + compareRouteCandidates, + compareRoutes, + coveringPrefixes, + hiddenBelow, + hooks, + scopeCaptures, + scopeHead, + scopeOverlaps, + spreadHash, +} from "./internal.ts"; import * as Path from "./path.ts"; import { type Advertised, type Advertisements, type Instance, registerWire, sameInstance, wireOf } from "./wire.ts"; @@ -235,21 +245,6 @@ function compareCandidates(prefix: Path.Valid, a: Candidate, b: Candidate): numb return ha < hb ? -1 : ha > hb ? 1 : 0; } -/** Orders two routes by preference: the newest epoch with none last, then identified before - * anonymous, then lower static cost. */ -function compareRoutes(a: Route, b: Route): number { - if (a.epoch !== b.epoch) { - // An older epoch is a publisher that was replaced. - if (a.epoch === undefined) return 1; - if (b.epoch === undefined) return -1; - return a.epoch > b.epoch ? -1 : 1; - } - const anonymous = Number(isAnonymous(a)) - Number(isAnonymous(b)); - if (anonymous !== 0) return anonymous; - if (a.cost !== b.cost) return a.cost < b.cost ? -1 : 1; - return 0; -} - /** * The preferred of `entries` (newest first) for resolving `path`, not skipped: the best route, * then fewest hops, then the lowest {@link spreadHash}, then newest. `path` is the requested @@ -269,13 +264,7 @@ function preferredEntry( } const a = entry.route.peek(); const b = best.route.peek(); - let order = compareRoutes(a, b) || a.hops.length - b.hops.length; - // Hashed only on a tie, so the common single-route prefix never pays for it. - if (order === 0) { - const ha = spreadHash(path, a.hops); - const hb = spreadHash(path, b.hops); - order = ha < hb ? -1 : ha > hb ? 1 : 0; - } + const order = compareRouteCandidates(path, a, b); if (order < 0) best = entry; } return best; @@ -753,22 +742,19 @@ class OriginState { /** The preferred entry on the most specific route covering `path`, ignoring skipped entries, if any. */ bestEntry(path: Path.Valid, skip?: (entry: RouteEntry) => boolean): RouteEntry | undefined { - let bestPrefix: Path.Valid | undefined; - let best: RouteEntry | undefined; - for (const [prefix, entries] of this.routes.peek() ?? []) { - if (!Path.hasPrefix(prefix, path)) continue; + const routes = this.routes.peek(); + if (!routes) return undefined; + for (const prefix of coveringPrefixes(path)) { + const entries = routes.get(prefix); + if (!entries) continue; const entry = preferredEntry( path, entries, (candidate) => !candidate.scope.matches(path) || (skip?.(candidate) ?? false), ); - if (!entry) continue; - if (bestPrefix === undefined || prefix.length > bestPrefix.length) { - bestPrefix = prefix; - best = entry; - } + if (entry) return entry; } - return best; + return undefined; } /** diff --git a/quest/m0/broadcast-epoch/README.md b/quest/m0/broadcast-epoch/README.md index 8f2e7d0521..f491e57b57 100644 --- a/quest/m0/broadcast-epoch/README.md +++ b/quest/m0/broadcast-epoch/README.md @@ -125,10 +125,13 @@ End+Start. The child quests own their focused regressions. - [Retired requests](/quest/m0/broadcast-epoch/retired-requests.md) - a held broadcast or track cannot open unpinned wire work against its replacement - [Resume duplicates](/quest/m0/broadcast-epoch/resume-duplicates.md) - a delayed group is delivered once even beyond the old 1024-entry history -- [JS consume identity](/quest/m0/broadcast-epoch/js-consume-identity.md) - requests carry the serving prefix's epoch and new winners cannot reuse an old cached broadcast - [JS restart keeps the request](/quest/m0/broadcast-epoch/js-restart-keeps-request.md) - a resolved request remains on its old instance, matching Rust's sticky subscriptions - [Source pin](/quest/m0/broadcast-epoch/source-pin.md) - `Source` is built from the resolved catalog broadcast and every later request stays on it, so an epochless replacement never splices into the old program - [Catalog references](/quest/m0/broadcast-epoch/catalog-references.md) - players and exporters pin siblings consistently, with optional reference epochs and documented legacy best effort - [Publish catalog restart](/quest/m0/broadcast-epoch/publish-catalog-restart.md) - `@moq/publish` never reuses catalog group numbers under one name and epoch after a re-announce - [Bindings](/quest/m0/broadcast-epoch/bindings.md) - moq-ffi and every wrapper expose the epoch and let a publisher announce one - [Stats totals and prefix tracks](/quest/m0/broadcast-epoch/stats-split.md) - the same release retires the per-path stats maps for totals and on-demand prefix tracks (decided 2026-10-05) + +## Related + +- [JS consume identity](https://github.com/moq-dev/moq/pull/5254) - requests carry the serving prefix's epoch and new winners cannot reuse an old cached broadcast diff --git a/quest/m0/broadcast-epoch/catalog-references.md b/quest/m0/broadcast-epoch/catalog-references.md index 3939fe58b1..38a850cb41 100644 --- a/quest/m0/broadcast-epoch/catalog-references.md +++ b/quest/m0/broadcast-epoch/catalog-references.md @@ -64,10 +64,11 @@ guarantee and the selected legacy best-effort behavior. ## Required - [Source pin](/quest/m0/broadcast-epoch/source-pin.md) - exporters share their resolved catalog handle -- [JS consume identity](/quest/m0/broadcast-epoch/js-consume-identity.md) - JS resolves the actual serving identity ## Related +- [JS consume identity](https://github.com/moq-dev/moq/pull/5254) - JS resolves the actual serving identity + - [Retired requests](/quest/m0/broadcast-epoch/retired-requests.md) - held handles cannot emit unpinned requests after retirement - [Catalog rendition IDs](/quest/m1/catalog-track-id.md) - the adjacent optional wire-track field - [Catalog track identity](/quest/m1/catalog-tracks.md) - catalog updates cannot redefine a track's content diff --git a/quest/m0/broadcast-epoch/js-consume-identity.md b/quest/m0/broadcast-epoch/js-consume-identity.md deleted file mode 100644 index 33935f89e5..0000000000 --- a/quest/m0/broadcast-epoch/js-consume-identity.md +++ /dev/null @@ -1,48 +0,0 @@ -# [M] JS consumes use the identity of the serving announcement - -## Goal - -`@moq/net` requests name the epoch of the announcement serving their path, -including covering prefixes. A newly winning announcement never gives a new -consumer a cached broadcast from the previous source. Existing consumers -remain on their instance until they drop it or its source ends. - -## Plan - -Reproduced in the 2026-10-10 epoch audit in `js/net/src/lite/subscriber.ts`: - -- Announce `pool` with epoch A, then consume `pool/job`: TRACK has no epoch - because the subscriber looks up only the exact path. Rust already uses - the resolved route's epoch. -- Consume `pool/job` through `pool`, then announce the more-specific - `pool/job` with epoch B: another consume returns the old cached broadcast. - Restart and End evict cached consumes, but the new Start does not. - -Bind request identity and cache reuse to the serving announcement. Reuse the -existing route-resolution rules rather than adding another interpretation of -prefix precedence. A more-specific epochless announcement must not inherit -the epoch of a broader one. Inspect the shared `BroadcastCache` and IETF -subscriber for the same stale-cache behavior; IETF has no wire epoch today, -but a different epochless source still cannot reuse the old instance. - -Add regressions for both failures, with exact-path requests as a control, -more-specific epochless routes, fallback after withdrawal, and old consumers -remaining readable while new consumers select the replacement. Same explicit -identity may reuse a broadcast; equal absent epochs do not prove identity. -Use the existing JS test lane and run cross-language interop. If routing or -invalidation work changes fanout costs, extend the relevant benchmark over -announcements and consumers instead of accepting a whole-table scan. - -Decided 2026-10-10: these correctness fixes gate m0. Seamless JS track -handover remains separate in m1; fixing request identity does not require -building its resume pump. Update stale comments and existing concept docs -inline, with no separate guide. - -Public API: corrected request and cache behavior, no new exported surface -expected. Wire: existing lite-07 epoch fields carry the resolved identity; -no framing change, and older versions stay epochless. - -## Related - -- [JS track handover](/quest/m1/js-group-handover.md) - preserves open tracks across routes of the same explicit identity -- [Retired requests](/quest/m0/broadcast-epoch/retired-requests.md) - separately prevents old callable handles from opening wire work against replacements; this quest owns fresh-consumer selection and cache reuse diff --git a/quest/m0/broadcast-epoch/retired-requests.md b/quest/m0/broadcast-epoch/retired-requests.md index 52d448d232..043bd57d2e 100644 --- a/quest/m0/broadcast-epoch/retired-requests.md +++ b/quest/m0/broadcast-epoch/retired-requests.md @@ -59,7 +59,7 @@ Update existing identity and request-lifetime documentation inline. ## Related - [Source pin](/quest/m0/broadcast-epoch/source-pin.md) - the exporter holds the exact catalog broadcast -- [JS consume identity](/quest/m0/broadcast-epoch/js-consume-identity.md) - fresh consumers select the serving announcement's identity +- [JS consume identity](https://github.com/moq-dev/moq/pull/5254) - fresh consumers select the serving announcement's identity - [JS restart keeps the request](/quest/m0/broadcast-epoch/js-restart-keeps-request.md) - an existing resolved request stays sticky - [IETF epochs](/quest/m1/ietf-epochs.md) - explicit wire identity removes the epochless ambiguity - [Pipelined first FETCH](/quest/m1/pipeline-requests/fetch.md) - staged requests must retain the same instance validity