Repository navigation
fix(net): bind JS consumes to the serving announcement #5254
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
6d07d31
chore(quest): claim JS consume identity
kixelated 0c2b6b1
fix(net): bind JS consumes to the serving announcement
kixelated 6b11dc1
chore(quest): retire JS consume identity
kixelated cb32d6f
chore: merge main into consume identity
kixelated 82708be
fix(net): retain held consume instances across route changes
kixelated File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Path.Valid, broadcast.Consumer>(); | ||
| #cache = new Map<Path.Valid, Map<Epoch.Valid | object, broadcast.Consumer>>(); | ||
| #announced = new Map<Path.Valid, Map<object, { identity: object; route: Route }>>(); | ||
| #blind = new Map<Path.Valid, Blind>(); | ||
| #blindPrefixes = new Map<Path.Valid, Set<Path.Valid>>(); | ||
|
|
||
| /** 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); | ||
| } | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.