feat(spf): multi-cdn support (#1668)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christian Pillsbury
2026-06-11 10:09:16 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent b9e0a75c1b
commit 00aa6247b8
11 changed files with 1141 additions and 211 deletions
@@ -124,56 +124,146 @@ function updateThroughputDisplay() {
throughputDiv.className = 'has-data';
}
// Signature of the currently-rendered video rendition set. Lets the picker
// rebuild its buttons only when the set actually changes — selection and ABR/
// manual changes update existing buttons in place (see renderRenditionPicker),
// so a click or hover isn't interrupted by a full DOM teardown on every ABR
// switch (selectedVideoTrackId changes frequently during playback).
let videoTrackSetKey = '';
function renderRenditionPicker() {
if (!engine || !signals) return;
const presentation = engine.state.presentation.get();
const selectedVideoTrackId = engine.state.selectedVideoTrackId.get();
const isManual = engine.state.userVideoTrackSelection.get() !== undefined;
const userFilter = engine.state.userVideoTrackSelection.get();
const tracks = getVideoTracks(presentation);
if (tracks.length === 0) {
renditionButtonsDiv.textContent = presentation ? 'No video tracks found' : 'Waiting for presentation…';
videoTrackSetKey = '';
return;
}
// Selection pins by bitrate + resolution (see the click handler), not track
// id, so redundant-stream renditions duplicated across CDNs share one
// selection identity and are NOT independently selectable. Collapse to one
// button per identity so the UI matches what selection actually guarantees.
const groups = getVideoSelectionGroups(tracks);
const setKey = groups.map((group) => group.key).join('|');
if (setKey !== videoTrackSetKey) {
videoTrackSetKey = setKey;
buildVideoTrackButtons(groups);
}
updateVideoTrackSelection(tracks, selectedVideoTrackId, userFilter);
}
// One selectable video identity: a bitrate + resolution. `key` is exactly what
// the click handler pins on (as a partial-track filter), so highlighting and
// dedupe share one notion of identity.
interface VideoSelectionGroup {
key: string;
label: string;
filter: { bandwidth: number; width?: number; height?: number };
members: string[];
}
/** Stable bitrate+resolution identity for a video track (matches the pin filter). */
function videoSelectionKey(track: ReturnType<typeof getVideoTracks>[number]): string {
const width = 'width' in track ? track.width : undefined;
const height = 'height' in track ? track.height : undefined;
return `${track.bandwidth}|${width ?? ''}×${height ?? ''}`;
}
/** Collapse video tracks to one entry per selection identity (bitrate + resolution). */
function getVideoSelectionGroups(tracks: ReturnType<typeof getVideoTracks>): VideoSelectionGroup[] {
const groups = new Map<string, VideoSelectionGroup>();
for (const track of tracks) {
const key = videoSelectionKey(track);
let group = groups.get(key);
if (!group) {
const width = 'width' in track ? track.width : undefined;
const height = 'height' in track ? track.height : undefined;
const res = width && height ? `${width}×${height} @ ` : '';
const filter: VideoSelectionGroup['filter'] = { bandwidth: track.bandwidth };
if (width) filter.width = width;
if (height) filter.height = height;
group = { key, label: `${res}${formatBandwidth(track.bandwidth)}`, filter, members: [] };
groups.set(key, group);
}
// Member ids (one per CDN for redundant streams) are surfaced in the
// tooltip so the collapsed renditions are still inspectable.
group.members.push(track.id);
}
return [...groups.values()];
}
/** (Re)build the static button list — one per selection group, tagged with its key. */
function buildVideoTrackButtons(groups: VideoSelectionGroup[]) {
renditionButtonsDiv.innerHTML = '';
const statusRow = document.createElement('div');
statusRow.id = 'video-status-row';
statusRow.className = 'abr-status';
const modeLabel = document.createElement('span');
modeLabel.className = isManual ? 'mode-manual' : 'mode-abr';
modeLabel.textContent = isManual ? '🔒 Manual' : '⟳ ABR';
statusRow.appendChild(modeLabel);
if (isManual) {
const enableBtn = document.createElement('button');
enableBtn.type = 'button';
enableBtn.className = 'enable-abr-btn';
enableBtn.textContent = 'Enable ABR';
enableBtn.addEventListener('click', () => {
log('ABR re-enabled', 'success');
signals.state.userVideoTrackSelection.set(undefined);
});
statusRow.appendChild(enableBtn);
}
renditionButtonsDiv.appendChild(statusRow);
for (const track of tracks) {
const isSelected = track.id === selectedVideoTrackId;
for (const group of groups) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = `rendition-btn${isSelected ? (isManual ? ' selected-manual' : ' selected-abr') : ''}`;
const res = 'width' in track && track.width && track.height ? `${track.width}×${track.height} @ ` : '';
const badge = isSelected ? (isManual ? ' 🔒' : ' ⟳') : '';
btn.textContent = `${res}${formatBandwidth(track.bandwidth)}${badge}`;
btn.title = track.id;
btn.dataset.selectionKey = group.key;
// Base label minus the selection badge; the badge is toggled in place.
btn.dataset.label = group.label;
btn.title =
group.members.length > 1
? `${group.members.length} rendition(s) across CDNs: ${group.members.join(', ')}`
: (group.members[0] ?? group.key);
btn.addEventListener('click', () => {
log(`Manual rendition select: ${formatBandwidth(track.bandwidth)} (ABR disabled)`, 'warning');
signals.state.userVideoTrackSelection.set({ id: track.id });
log(`Manual rendition select: ${JSON.stringify(group.filter)} (ABR disabled)`, 'warning');
signals.state.userVideoTrackSelection.set(group.filter);
});
renditionButtonsDiv.appendChild(btn);
}
}
/** Update the status row and per-button selected state without tearing down. */
function updateVideoTrackSelection(
tracks: ReturnType<typeof getVideoTracks>,
selectedVideoTrackId: string | undefined,
userFilter: SimpleHlsEngineState['userVideoTrackSelection']
) {
const isManual = userFilter !== undefined;
const statusRow = document.getElementById('video-status-row');
if (statusRow) {
statusRow.innerHTML = '';
const modeLabel = document.createElement('span');
modeLabel.className = isManual ? 'mode-manual' : 'mode-abr';
modeLabel.textContent = isManual ? `🔒 Manual: ${JSON.stringify(userFilter)}` : '⟳ ABR';
statusRow.appendChild(modeLabel);
if (isManual) {
const enableBtn = document.createElement('button');
enableBtn.type = 'button';
enableBtn.className = 'enable-abr-btn';
enableBtn.textContent = 'Enable ABR';
enableBtn.addEventListener('click', () => {
log('ABR re-enabled', 'success');
signals.state.userVideoTrackSelection.set(undefined);
});
statusRow.appendChild(enableBtn);
}
}
// The selected track belongs to a group keyed by its bitrate + resolution;
// highlight that group's button.
const selectedTrack = tracks.find((track) => track.id === selectedVideoTrackId);
const selectedKey = selectedTrack ? videoSelectionKey(selectedTrack) : undefined;
for (const btn of renditionButtonsDiv.querySelectorAll<HTMLButtonElement>('button[data-selection-key]')) {
const isSelected = btn.dataset.selectionKey === selectedKey;
btn.className = `rendition-btn${isSelected ? (isManual ? ' selected-manual' : ' selected-abr') : ''}`;
const badge = isSelected ? (isManual ? ' 🔒' : ' ⟳') : '';
btn.textContent = `${btn.dataset.label ?? ''}${badge}`;
}
}
// Signature of the currently-rendered audio track set. Lets the picker rebuild
// its buttons only when the track set actually changes — selection and pin
// changes update existing buttons in place (see renderAudioTrackPicker) so a
+240 -173
View File
@@ -1,211 +1,278 @@
---
status: draft
date: 2026-05-20
definition: coarse
date: 2026-06-05
definition: sketched
---
# Multi-CDN failover
Alternate-URI rotation for HLS sources with multiple CDN paths to the
same content. When a fetch fails on the active URI (after
[network-resilience](./network-resilience.md)'s retries are
exhausted), rotate to the next URI in the rendition's alternate-URI
list. Mux Video produces such sources via the `?redundant_streams=true`
playback URL parameter; HLS spec / vendor conventions provide the
manifest-side declaration. Cluster G sister feature to
`network-resilience`; consumes the foundation's retry + circuit-
breaker primitives and adds the URI-rotation policy on top.
CDN selection and failover for HLS sources that publish the same content
on more than one host (e.g. Mux Video's `?redundant_streams=true`). The
redundant variants parse as ordinary candidate tracks — one per
(rendition × CDN) — so the work is **selecting which CDN to use and
keeping the whole presentation on it**, modeled inside the
[track-switching rule model](../track-switching-model.md) rather than as
URL rewriting at fetch time.
A **Media-src feature** for sources that genuinely require it
(redundant-streams sources where the customer expects automatic
failover) and a **Player feature** at Tier 2 (customer-customizable
rotation policies). Notion epic #9 classifies as "Media-src? /
Player?" — the ambiguity reflects the dual scope.
Two sub-features, mapping onto the two rule kinds in that model:
1. **Sticky CDN pick** *(implemented)* — a session-level behavior picks a
CDN (the manifest-head host) and holds it; a shared **scope** rule
(`preferActiveCdn`) narrows every track type's candidates to that CDN,
so video / audio / text resolve from one host. This is the
*active-pathway scope* the track-switching model lists for
multi-cdn-failover.
2. **Failover** *(deferred)* — when requests to the active CDN fail too
often within a window, a **constraint** removes that CDN's tracks from
the candidate set during cooldown and the session behavior rotates the
active CDN. This is the *failed-CDN constraint* the model lists, and it
consumes [network-resilience](./network-resilience.md)'s per-host
circuit-breaker — its hard prerequisite.
A **Media-src feature** for sources that genuinely require failover, with
a **Player feature** surface at the failover tier (customer-customizable
CDN policy). Notion epic #9 classifies as "Media-src? / Player?" — the
ambiguity reflects that split.
## Status
- **Composition:** not implemented in `createSimpleHlsEngine`. The
parser doesn't recognize alternate-URI declarations; no rotation
policy state; no failover behavior. Single-URI behavior throughout.
- **Definition depth:** coarse — scope from Notion epic + Mux Video
convention + network-resilience composition; SPF touchpoints
sketched at the cluster level. Implementation details (parser
syntax, state-slot shape, rotation defaults) tracked as open
questions.
- **Hard prerequisite:** [network-resilience](./network-resilience.md).
Rotation triggers on the foundation's retry-exhaustion signal;
per-URI health tracking consumes the foundation's circuit-breaker
state.
- **Composition:** sub-feature 1 (sticky CDN pick) is implemented in
`createSimpleHlsEngine` and `createHlsAudioOnlyEngine`. The
`resolveCdnPriority` behavior owns the `cdnPriority` signal (the
manifest-ordered CDN list, most-preferred first); the `preferActiveCdn`
scope rule (shared by the video + audio chains in `track-switching`)
narrows candidates to the highest-priority CDN with surviving tracks.
Failover (sub-feature 2) is not implemented — no constraints pass, no
per-CDN failure tracking, no rotation.
- **Definition depth:** sketched — sub-feature 1 has a populated
implementation surface + verification; sub-feature 2 stays at the
scope-and-constraints level pending its prerequisite.
- **Hard prerequisite (failover only):**
[network-resilience](./network-resilience.md). The failed-CDN
constraint consumes the foundation's per-host circuit-breaker /
retry-exhaustion state. Sub-feature 1 has no such dependency — it's
pure selection over the already-parsed candidate set.
- **Governing model:** [track-switching-model.md](../track-switching-model.md)
— multi-CDN is the canonical *constraint + scope* feature there. The
active-CDN scope is a soft filter in the rule chain; the failed-CDN
constraint is a hard filter in the (not-yet-built) constraints pre-pass.
## How redundant streams are modeled
There is no `alternateUris` field and no fetch-time URL rotation. A
redundant-streams manifest lists each rendition once per CDN (duplicate
`#EXT-X-STREAM-INF` / `#EXT-X-MEDIA` entries on different hosts), and the
existing parser already emits one `Track` per entry with a unique id and
its own absolute `url`. So the candidate set for each type *already*
contains one variant per CDN. CDN identity is derived from each track
URL's origin (`getCdnId`); the set of CDNs is published as a single
per-presentation ordered signal (`cdnPriority`, most-preferred first —
mirroring HLS content steering's `PATHWAY-PRIORITY`). The *active* CDN is
not stored: the scope derives it as the highest-priority `cdnPriority`
entry that still has tracks after the constraints pass. Selecting a
CDN-tagged track id means `resolveTrack` / segment loading fetch from that
CDN with no further plumbing.
This list shape is what makes failover fall out cleanly: the failed-CDN
constraint (sub-feature 2) prunes a cooled-down CDN's tracks, so
"first-with-survivors" moves to the next CDN automatically and returns to
the primary when it recovers — no reactive rewrite of an "active" value.
Content steering, likewise, just reorders `cdnPriority` (pathway priority
as a sort key).
This is why sub-feature 1 needed **no parser change** and no new data
shape — only a selection behavior and a scope rule over existing tracks.
## Phases of complexity
[Tier 1 / Tier 2 framing](./clusters.md#tier-1-spec-compliant-baseline-vs-tier-2-custom-behavior)
per Notion epic #9 ("Tier 1: Parse spec-extension alternate URIs.
Tier 2: Rotation policy, backoff strategy."). Each phase notes Naive
vs Full depth where relevant per the
[Naive vs Full framing](./clusters.md#naive-vs-full-implementation-depth).
| Phase | Tier | What | Notes |
|---|---|---|---|
| Alternate-URI parsing + presentation surfacing | Tier 1 | Parser extracts alternate-URI lists from multivariant playlist (HLS spec extension or vendor convention; syntax open). Presentation `Track` data shape grows an `alternateUris: string[]` field (or similar) on each rendition | Parser extension; [presentation-modeling](../presentation-modeling.md)'s `Track` shape grows. Tier 1 spec-compliant baseline: surface what the manifest says. **Naive:** parse the simplest known syntax (Mux convention). **Full:** support multiple alternate-URI declarations across HLS spec drafts + vendor variants |
| Active-URI state + initial selection | Tier 1 | New state slot — per-rendition active-URI tracking (e.g., `selectedRenditionUris: Map<TrackId, string>` or per-Track field on resolved presentation). Initial value: first URI in each rendition's `alternateUris` list. Behaviors consuming `Track.uri` (segment loading, playlist reload, manifest fetch) read the active URI rather than the canonical URI | Constraint+filter shape: active-URI slot is the read-side for downstream consumers; rotation policy (Tier 2) is the write-side. Without rotation, this phase is degenerate-equivalent to single-URI behavior — Tier 1 alone provides parsing but not failover |
| Rotation on retry-exhaustion | Tier 2 | When `network-resilience` exhausts retries on the active URI for a given rendition, rotate to the next URI in the list. Active-URI slot updates; consumers re-fetch using the new URI. The rotation policy controls *which* URI is chosen next | Consumes [network-resilience](./network-resilience.md)'s retry-exhaustion signal. **Naive:** round-robin through the list. **Full:** primary-preferred-with-fallback (return to primary when its circuit-breaker cools), or weighted, or region-aware. Live + multi-CDN composition: reload-loop failover during live consumes this phase too |
| Per-URI health tracking | Tier 2 | Combine `network-resilience`'s per-URI circuit-breaker state into a health score per alternate URI. Rotation reads health when choosing next URI — skip known-unhealthy URIs without trying them. Health values surface from the breaker's `healthy` / `cooldown` / `unhealthy` state | Consumes `network-resilience`'s circuit-breaker primitive. Likely a derived signal (computed from breaker state). **Naive:** binary healthy/unhealthy from breaker state. **Full:** time-decayed health score that distinguishes "recently-cooled" from "long-healthy" |
| Customer-policy hooks | Tier 2 | Pluggable hooks: `selectAlternateUri(failedUri, candidates, history) → string`. Customer can override default rotation (region-preferred ordering, weighted, A/B testing, regulatory-compliant routing) | Tier 2 customer-policy surface. Built-in defaults; hooks override when set. Adapter-layer customer-facing toggles ("prefer CDN A" UI) wire through these hooks |
| Phase | Sub-feature | Kind | What | State |
|---|---|---|---|---|
| Sticky CDN pick | 1 | scope | `resolveCdnPriority` publishes the manifest-ordered CDN list (`cdnPriority`); `preferActiveCdn` narrows every type's candidates to the highest-priority CDN with surviving tracks, falling through when nothing matches. Shared list → all types on one CDN | **Implemented** |
| Failed-CDN constraint | 2 | constraint | A constraint in the track-switching constraints pre-pass removes a cooled-down CDN's tracks from the candidate set. The scope then picks the next `cdnPriority` entry automatically. Requires the generic constraints phase (track-switching-model "Phase 2") to be built first | Deferred |
| Per-CDN failure tracking | 2 | — | Count per-CDN fetch failures within a window; mark a CDN unhealthy / in cooldown. Consumes `network-resilience`'s circuit-breaker | Deferred (prereq) |
| CDN priority override / steering | 2 | scope | Reorder `cdnPriority` to bias the pick (region-preferred, weighted, or content-steering's pathway priority). No reactive "active" rewrite needed — the order *is* the policy | Deferred |
| Customer CDN-id derivation | 2 | config | Pluggable `getCdnId` for non-origin identity. The origin-based default is built in; a config seam is anticipated | Deferred |
## What's in scope vs out of scope
**In scope:**
- All five phases above for HLS sources with alternate-URI
declarations
- Parser support for alternate-URI lists (Mux convention syntax + any
HLS spec extension forms)
- Active-URI state slot + rotation policy
- Integration with `network-resilience`'s retry-exhaustion + circuit-
breaker primitives
- Customer-pluggable rotation hooks
- Live + multi-CDN composition (reload-loop failover during live
streams)
- Sticky per-presentation CDN selection (sub-feature 1, done)
- Failover via a track-switching constraint + active-CDN rotation
(sub-feature 2)
- Per-CDN health derived from `network-resilience`'s circuit-breaker
- Customer-configurable CDN-id derivation / rotation policy
**Out of scope (separate cluster G sister features):**
- **[network-resilience](./network-resilience.md)** *(foundation,
prerequisite)* — retry + backoff + circuit-breaker. Multi-CDN
consumes; doesn't reimplement.
- **[content-steering](./content-steering.md)** — HLS content-
steering protocol. Server-side host-pool advertisement (dynamically
updated). Different mechanism than static alternate-URI lists.
Content-steering's pathway-priority composes with this feature's
rotation primitive: pathway-priority is the dynamic ordering bias
(a sort key); static manifest alternate-URI lists are the static
candidate set.
prerequisite for failover)* — retry + backoff + circuit-breaker.
Multi-CDN consumes; doesn't reimplement.
- **[content-steering](./content-steering.md)** — HLS content-steering
protocol (server-advertised, dynamically-updated host pool). Different
mechanism than static redundant streams, but the *same* active-pathway
scope shape: content-steering picks the active pathway dynamically; the
scope reflecting it is the one implemented here. Designed-with-in-mind:
`cdnPriority` is a reorderable list a steering behavior writes (pathway
priority as a sort key), and the scope honors the order unchanged.
**Out of scope (different architectural layer):**
- Adapter-layer customer-facing UI surfaces (e.g., "Switch CDN"
buttons, region-preferred dropdowns). Consumer policy expressed via
this feature's Tier 2 hooks.
- CDN-side load balancer / origin-shield / health-check infrastructure.
Service-side concerns; engine reacts to what the CDN responds with.
- DRM key-server failover. Even when license fetches are CDN-routed,
the failover concern lives under [drm-support](./drm-support.md)
(license-fetch retries) + this feature's primitive may compose, but
the key-server-specific rotation policy is DRM-side state.
- Adapter-layer customer-facing UI ("Switch CDN" buttons). Consumer
policy is expressed via the failover tier's config seam.
- CDN-side load balancer / origin-shield infrastructure. Service-side.
- DRM key-server failover — lives under [drm-support](./drm-support.md).
## Likely cross-cutting impact
Things this feature probably forces decisions on, not just additions:
- **Per-rendition vs per-presentation active URI.** Each rendition
can have its own alternate-URI list (different CDN paths per
bitrate variant) OR all renditions share the same active-URI
index. Per-rendition is more flexible (one rendition's CDN can be
unhealthy while others are fine); per-presentation is simpler
(one rotation state for the source). Lean: per-rendition. Affects
state-slot shape (`Map<TrackId, string>` vs single index).
- **Active-URI slot writer composition.** This feature writes the
active URI; downstream behaviors read it. Single-writer slot —
this feature's rotation behavior is sole writer. The slot is read
by segment-loading, playlist-reload (when live-stream-support
lands), manifest-fetch. Standard constraint+filter pattern.
- **Parser surface for alternate-URI declarations.** HLS spec
extensions vary; Mux uses one convention. Parser-pluggability
question from [presentation-modeling](../presentation-modeling.md)
is sharpened by this feature — alternate-URI parsing extends the
`parseMediaPlaylist` / `parseMultivariantPlaylist` schema. Likely
HLS-only initially; format-extension to DASH/MoQ adds different
shapes.
- **Live + multi-CDN composition.** During live playback, manifest
reload-loop fetches periodically. Reload-fetch retry-exhaustion
should trigger rotation (and the new URI's reload-loop continues).
Cross-feature with [live-stream-support](./live-stream-support.md)
(not implemented yet).
- **Composition with `[content-steering]`.** Content-steering's
server-advertised host pool changes the rotation's candidate set
dynamically. Two composition shapes: (a) content-steering writes
to the `alternateUris` list (replacing the static manifest values);
(b) content-steering writes a separate `steeredHosts` slot that
composes with `alternateUris` (intersect, prefer, etc.). Open
question — when content-steering lands.
- **Rotation state across source changes.** When the consumer changes
`presentation.url`, the active-URI state tears down with the source
(per [source-replacement](./source-replacement.md)'s cascade). Per-
URI circuit-breaker state in `network-resilience` may persist across
sources for the same hosts (cross-source-resilience benefit).
- **Per-stream-type rotation coordination.** A presentation with
separate audio and video URIs (each possibly with their own
alternate-URI lists) can rotate them independently. Live + multi-
CDN with per-track rotation: each track's reload-loop manages its
own active-URI rotation. Inherits live-stream-support's per-type
reload-coordination open question.
- **Per-presentation, not per-rendition (resolved).** A single
`cdnPriority` list governs all track types — the per-presentation
coherence requirement. The track-switching model's
"cross-type consistency is a composition convention" applies: both the
video and audio chains reference the *same* `preferActiveCdn` definition
reading the *same* list, so they agree on the CDN even if their per-type
track arrays differ. (The doc's earlier per-rendition lean is superseded.)
- **`cdnPriority` writer composition.** `resolveCdnPriority` is the sole
writer today (publishes the manifest order). Failover needs no second
writer — the failed-CDN constraint prunes tracks and the scope re-derives
the active CDN. Content-steering would *reorder* `cdnPriority` (still a
single owning behavior; the list reflects one upstream priority).
- **Active CDN is derived, not stored.** The scope computes "highest
priority with surviving tracks," so failover and recovery need no extra
state: pruning moves the pick to the next CDN, un-pruning returns it to
the primary. This is why the array beats a single reactive `activeCdn`
value — the failed-set information is applied once (in the constraint),
not duplicated into an active-value rewrite.
- **Constraints phase is a shared prerequisite.** The failed-CDN
constraint can't land until the generic constraints pre-pass
(`applyConstraints` + the `constraints` config field + empty-playable-set
terminal state) is built into `setupTrackSwitching`. The seam exists
(`candidateSet` computed); the machinery does not. capability-probing
shares this prerequisite.
- **Live + multi-CDN.** During live playback the reload loop re-resolves
the presentation; `resolveCdnPriority` re-publishes only when the CDN set
changes (idempotent for a stable manifest). Cross-feature with
[live-stream-support](./live-stream-support.md) (not yet implemented).
- **Priority state across source changes.** `cdnPriority` tears down with
the source via the resolved/unresolved cascade (per
[source-replacement](./source-replacement.md)); per-host circuit-breaker
state in `network-resilience` may outlive a source.
## Open questions
- **Alternate-URI manifest syntax.** HLS spec extension(s) vs Mux
convention vs both. Parser scope question. Open until the first
alternate-URI-bearing manifest lands as a test fixture.
- **Per-rendition vs per-presentation active URI.** Per the cross-
cutting note; lean per-rendition for flexibility.
- **Default rotation policy.** Round-robin vs primary-preferred vs
weighted. Lean: primary-preferred-with-circuit-breaker-cooldown-
return.
- **Composition with content-steering.** Static `alternateUris`
manifest values + dynamic content-steering host-pool: how to
combine? Replacement vs intersection vs preference order?
- **Rotation-state preservation.** Reset on source change (default)
vs preserve via the `bandwidthState`-style cross-source-survival
pattern (rare in this case — rotation state is per-URI, and URIs
are per-source).
- **Customer-hook contract.** Function signature, async semantics,
failover-after-hook-failure policy. Same shape question as
network-resilience's hook design; harmonize.
- **DRM license-fetch interaction.** When `drm-support` lands, license
fetches go through CDN routing too. Multi-CDN rotation for license
fetches: same feature, or DRM-side?
- **Per-stream-type rotation coordination.** Independent rotation
per type (video / audio / text) is the default; whether to allow
coordinated rotation (single failover decision rotates all types)
is an open Tier 2 question.
- **Constraints-phase shape.** How `applyConstraints` and the
empty-playable-set terminal state are modeled — owned by the
track-switching constraints work, consumed here. (See
[track-switching-model.md](../track-switching-model.md) → *Fitting the
model to the track-switching behavior*.)
- **Failure-window policy.** Threshold count / window length / cooldown
duration for marking a CDN unhealthy. Empirical; lives with
`network-resilience`'s circuit-breaker.
- **CDN-id derivation configurability.** Origin-based `getCdnId` is the
default; whether/where to expose a consumer override (config field vs.
rule config view) is deferred until a non-origin case appears.
- **Composition with content-steering.** Static redundant CDNs +
dynamic steered host pool: does steering replace, intersect, or
reprioritize the candidate CDNs? Open until content-steering lands; the
reorderable `cdnPriority` list keeps it tractable (steering reorders it).
### Resolved during sub-feature 1 implementation
- **Signal shape** → a per-presentation ordered `cdnPriority` list (active
= first-with-survivors, derived), not a single stored `activeCdn` value
and not per-rendition. The list makes failover a pure constraint and
composes with content-steering as a reorder. Matches the cross-type
coherence requirement (one shared list).
- **CDN identity** → URL origin (`getCdnId`); configurable derivation
deferred.
- **Manifest syntax / parser** → no change. Redundant variants already
parse as separate per-CDN tracks; no `alternateUris` field needed.
- **Architecture** → constraint + scope in the track-switching model, not
active-URI rotation in `resolveTrack`.
## Implementation surface
- **`packages/spf/src/media/utils/cdn.ts`** — `getCdnId(url)` (origin-based
CDN identity) and `getOrderedCdnIds(presentation)` (distinct CDNs in
manifest order; head = primary).
- **`packages/spf/src/playback/behaviors/resolve-cdn-priority.ts`** —
`resolveCdnPriority` behavior + `ResolveCdnPriorityState`. Machine reactor
on `presentation-unresolved``presentation-resolved`; owns the
`cdnPriority` signal; publishes `getOrderedCdnIds(presentation)` (skipping
the write when the CDN set is unchanged), clears on exit.
- **`packages/spf/src/playback/behaviors/track-switching.ts`** —
`preferActiveCdn` scope rule (soft filter on `cdnPriority`: narrow to the
highest-priority CDN with surviving tracks), added to both variants'
chains: `[filterByUserSelection, preferActiveCdn, rankByBandwidth]`.
`SwitchableTrack` gains `url` (the rule's input).
- **`packages/spf/src/playback/engines/hls/engine.ts` +
`engine-audio-only.ts`** — `resolveCdnPriority` composed after
`resolvePresentation`; `cdnPriority?: string[]` added to both engine state
interfaces.
State signal: `cdnPriority?: string[]` (CDN origins, most-preferred first),
owned by `resolveCdnPriority`, read optionally by `preferActiveCdn`.
## Verification
Sub-feature 1:
- `media/utils/tests/cdn.test.ts``getCdnId` (origin extraction;
same/different host; scheme+port; unparseable fallback);
`getOrderedCdnIds` (distinct CDNs in order; dedupe; single-CDN;
unresolved → `[]`).
- `playback/behaviors/tests/resolve-cdn-priority.test.ts` — publishes the
manifest-ordered CDN list; single-CDN source; skips the write when a
resolved swap keeps the same CDNs; updates when the order changes; clears
on src unload + on destroy; re-publishes after a src reset.
- `playback/behaviors/tests/track-switching.test.ts` (`preferActiveCdn`
block) — narrows to the highest-priority CDN overriding manifest track
order; keeps the pick on the primary; falls through to the next CDN when
the first has no survivors; falls through to all when none match; no-op
when `cdnPriority` absent; re-picks reactively when the order changes
(steering/failover seam); same scope applied to the audio chain
(cross-type coherence).
- `playback/engines/hls/tests/engine.test.ts` — integration: a
redundant-stream presentation yields `cdnPriority` = manifest order and a
video selection on the primary; reordering `cdnPriority` re-narrows and
the selection follows.
Out of scope / deferred:
- End-to-end + sandbox verification against a real Mux
`?redundant_streams=true` source (needs a fixture).
- All of sub-feature 2 (failover): blocked on the constraints phase +
`network-resilience`.
## Related features
- **[network-resilience](./network-resilience.md)** *(hard
prerequisite)* — retry + backoff + circuit-breaker foundation.
Multi-CDN consumes the retry-exhaustion signal (rotation trigger)
and the per-URI circuit-breaker state (per-URI health tracking).
- **[content-steering](./content-steering.md)** — parallel sister;
dynamic host-pool advertisement variant. Pathway-priority composes
with this feature's rotation primitive (sort-key shape).
- **[presentation-modeling](../presentation-modeling.md)** — `Track`
data shape grows `alternateUris` field; parser extension is in
scope here.
- **[live-stream-support](./live-stream-support.md)** *(not yet
implemented)* — reload-loop failover during live consumes this
feature's rotation primitive. Per-type reload coordination open
question applies.
- **[source-replacement](./source-replacement.md)** — active-URI
state tears down via the resolved/unresolved cascade on source
change.
- **[mse-mms-pipeline](./mse-mms-pipeline.md)** — segment-fetch
sites consume the active-URI slot indirectly via `Track.uri` reads.
- **[drm-support](./drm-support.md)** *(not implemented)* — license-
fetch failover question: same feature's rotation, or DRM-side?
- **[video-abr](./video-abr.md)** / **[audio-abr](./audio-abr.md)** —
ABR operates within a rendition; rotation operates on the rendition's
URI. Orthogonal axes; both compose.
- **[track-switching-model.md](../track-switching-model.md)** *(governing
model)* — multi-CDN is its canonical constraint + scope feature; the
active-CDN scope is implemented against the rule chain it specifies.
- **[network-resilience](./network-resilience.md)** *(hard prerequisite
for failover)* — per-host circuit-breaker the failed-CDN constraint
consumes.
- **[content-steering](./content-steering.md)** — dynamic host-pool
sibling; shares the active-pathway scope shape (`cdnPriority` as a
reorderable reflected list — pathway priority as a sort key).
- **[capability-probing](./capability-probing.md)** — shares the
not-yet-built constraints pre-pass with the failed-CDN constraint.
- **[live-stream-support](./live-stream-support.md)** *(not implemented)*
— reload-loop re-resolution; `cdnPriority` is republished only when the
CDN set changes.
- **[source-replacement](./source-replacement.md)** — `cdnPriority` tears
down via the resolved/unresolved cascade on source change.
- **[video-abr](./video-abr.md)** / **[audio-abr](./audio-abr.md)** — ABR
ranks within the CDN-narrowed set; the scope runs before the ranker.
## See also
- [track-switching-model.md](../track-switching-model.md) — the rule
model (constraints → soft filters → ranker) this feature composes into
- [clusters.md § Selection resilience](./clusters.md#selection-resilience)
— cluster G description; this feature is the selection-side
resilience sister to `network-resilience`'s response-error
handling
- [clusters.md § Feature classification axes](./clusters.md#feature-classification-axes)
— Tier 1 / Tier 2 framing; Media-src? / Player? classification
ambiguity
- [presentation-modeling.md](../presentation-modeling.md) — parser-
pluggability open question; alternate-URI parsing is one
forcing function
- [network-resilience.md](./network-resilience.md) — hard prerequisite;
retry + circuit-breaker foundation
— cluster G; this feature is the selection-side resilience sister to
`network-resilience`'s response-error handling
- [clusters.md § Selection / filtering across clusters](./clusters.md#selection--filtering-across-clusters)
cluster G's role: alternate-CDN selection within the chosen track
- [network-resilience.md](./network-resilience.md) — circuit-breaker
foundation the failover tier consumes
- [SPF Epics Working Doc](https://www.notion.so/35f97a7f89d08123a13fecab1ca1cac4)
— source material; epic #9 (Multi-CDN Failover)
- [Mux Video — `?redundant_streams=true`](https://www.mux.com/docs/guides/play-back-on-multiple-cdns)
+57
View File
@@ -0,0 +1,57 @@
import type { MaybeResolvedPresentation, TrackType } from '../types';
/**
* Identify the CDN a URL is served from, used to group redundant-stream
* variants that point at the same content on different hosts. Defaults to the
* URL's origin (scheme + host + port); falls back to the raw string when the
* URL can't be parsed, so the return value is always a stable grouping key.
*
* Sub-feature 1 (sticky CDN pick) uses origin-based identity; a more advanced
* or consumer-configurable derivation can replace this default later.
*/
export function getCdnId(url: string): string {
try {
return new URL(url).origin;
} catch {
return url;
}
}
// Track-type priority for CDN ordering: video first, then audio, then text.
// Selection sets are visited in this order so the head of the returned list is
// always video-derived. `preferActiveCdn` anchors every track type to the
// first CDN with surviving tracks (the head), so this makes "the primary CDN
// is the video CDN" a guarantee of `getOrderedCdnIds` rather than a side effect
// of the order tracks happen to be parsed in.
const CDN_TYPE_PRIORITY: Record<TrackType, number> = { video: 0, audio: 1, text: 2 };
/**
* The distinct CDNs a presentation's tracks are served from, ordered video CDNs
* first, then audio, then text (manifest order within a type). The head is the
* primary CDN the one a sticky pick defaults to and is always video-derived
* when the source has video. Returns `[]` for an unresolved presentation with
* no tracks.
*
* Redundant-stream sources list the same content on multiple hosts (e.g. Mux's
* `?redundant_streams=true`), so each host contributes its own candidate tracks;
* this collapses them to the set of CDNs across every track type.
*/
export function getOrderedCdnIds(presentation: MaybeResolvedPresentation): string[] {
const seen = new Set<string>();
const ids: string[] = [];
// Stable sort keeps manifest order among same-type selection sets.
const selectionSets = [...(presentation.selectionSets ?? [])].sort(
(a, b) => CDN_TYPE_PRIORITY[a.type] - CDN_TYPE_PRIORITY[b.type]
);
for (const selectionSet of selectionSets) {
for (const switchingSet of selectionSet.switchingSets) {
for (const track of switchingSet.tracks) {
const id = getCdnId(track.url);
if (seen.has(id)) continue;
seen.add(id);
ids.push(id);
}
}
}
return ids;
}
@@ -0,0 +1,121 @@
import { describe, expect, it } from 'vitest';
import type { MaybeResolvedPresentation } from '../../types';
import { getCdnId, getOrderedCdnIds } from '../cdn';
const presentationWith = (urlsByType: {
video?: string[];
audio?: string[];
text?: string[];
}): MaybeResolvedPresentation => ({
url: 'https://cdn-a.example.com/master.m3u8',
selectionSets: (['video', 'audio', 'text'] as const)
.filter((type) => urlsByType[type]?.length)
.map((type) => ({
id: `${type}-set`,
type,
switchingSets: [
{
id: `${type}-switching`,
type,
tracks: urlsByType[type]!.map((url, i) => ({ id: `${type}-${i}`, type, url, bandwidth: 0 })),
},
],
})) as MaybeResolvedPresentation['selectionSets'],
});
describe('getCdnId', () => {
it('returns the origin of an absolute URL', () => {
expect(getCdnId('https://cdn-a.example.com/path/720p.m3u8')).toBe('https://cdn-a.example.com');
});
it('treats different paths on the same host as the same CDN', () => {
const a = getCdnId('https://cdn-a.example.com/720p.m3u8');
const b = getCdnId('https://cdn-a.example.com/1080p/index.m3u8');
expect(a).toBe(b);
});
it('treats different hosts as different CDNs', () => {
const a = getCdnId('https://cdn-a.example.com/720p.m3u8');
const b = getCdnId('https://cdn-b.example.com/720p.m3u8');
expect(a).not.toBe(b);
});
it('distinguishes scheme and port via the origin', () => {
expect(getCdnId('https://cdn.example.com/x.m3u8')).not.toBe(getCdnId('http://cdn.example.com/x.m3u8'));
expect(getCdnId('https://cdn.example.com:8443/x.m3u8')).not.toBe(getCdnId('https://cdn.example.com/x.m3u8'));
});
it('falls back to the raw string when the URL cannot be parsed', () => {
expect(getCdnId('not a url')).toBe('not a url');
});
});
describe('getOrderedCdnIds', () => {
it('lists distinct CDNs across all track types in manifest order', () => {
const presentation = presentationWith({
video: ['https://cdn-a.example.com/720p.m3u8', 'https://cdn-b.example.com/720p.m3u8'],
audio: ['https://cdn-a.example.com/audio.m3u8', 'https://cdn-b.example.com/audio.m3u8'],
});
expect(getOrderedCdnIds(presentation)).toEqual(['https://cdn-a.example.com', 'https://cdn-b.example.com']);
});
it('dedupes repeated hosts, keeping first occurrence', () => {
const presentation = presentationWith({
video: [
'https://cdn-a.example.com/720p.m3u8',
'https://cdn-a.example.com/1080p.m3u8',
'https://cdn-b.example.com/720p.m3u8',
],
});
expect(getOrderedCdnIds(presentation)).toEqual(['https://cdn-a.example.com', 'https://cdn-b.example.com']);
});
it('returns a single CDN for a non-redundant source', () => {
const presentation = presentationWith({ video: ['https://cdn-a.example.com/720p.m3u8'] });
expect(getOrderedCdnIds(presentation)).toEqual(['https://cdn-a.example.com']);
});
it('returns [] for an unresolved presentation', () => {
expect(getOrderedCdnIds({ url: 'https://cdn-a.example.com/master.m3u8' })).toEqual([]);
});
it('orders video CDNs ahead of audio regardless of selection-set order', () => {
// Audio selection set listed first, cdn-b ahead of cdn-a within it; video
// listed second with cdn-a first. Raw manifest order would make cdn-b primary,
// but the head must be video-derived (cdn-a).
const presentation: MaybeResolvedPresentation = {
url: 'https://cdn-a.example.com/master.m3u8',
selectionSets: [
{
id: 'audio-set',
type: 'audio',
switchingSets: [
{
id: 'audio-switching',
type: 'audio',
tracks: [
{ id: 'aud-b', type: 'audio', url: 'https://cdn-b.example.com/audio.m3u8', bandwidth: 0 },
{ id: 'aud-a', type: 'audio', url: 'https://cdn-a.example.com/audio.m3u8', bandwidth: 0 },
],
},
],
},
{
id: 'video-set',
type: 'video',
switchingSets: [
{
id: 'video-switching',
type: 'video',
tracks: [
{ id: 'vid-a', type: 'video', url: 'https://cdn-a.example.com/720p.m3u8', bandwidth: 0 },
{ id: 'vid-b', type: 'video', url: 'https://cdn-b.example.com/720p.m3u8', bandwidth: 0 },
],
},
],
},
] as MaybeResolvedPresentation['selectionSets'],
};
expect(getOrderedCdnIds(presentation)).toEqual(['https://cdn-a.example.com', 'https://cdn-b.example.com']);
});
});
@@ -0,0 +1,91 @@
/**
* **Session-level CDN priority.** While a presentation is resolved, owns the
* `cdnPriority` signal: the distinct CDNs the source is served from (origin of
* each track's URL), in manifest priority order most-preferred first. Cleared
* on src unload. The name mirrors HLS content steering's `PATHWAY-PRIORITY`.
*
* Redundant-stream sources (e.g. Mux's `?redundant_streams=true`) list the same
* content on multiple hosts, so the candidate tracks already include one variant
* per CDN. This behavior publishes *which* CDNs exist and their priority; the
* `preferActiveCdn` scope rule in `track-switching` reads `cdnPriority` and
* narrows each type's candidates to the first CDN with surviving tracks so
* video / audio / text all resolve from one host (the shared list is the
* per-presentation coherence guarantee).
*
* The "active" CDN is not stored it's derived by the scope as the
* highest-priority entry in `cdnPriority` that still has tracks after the
* constraints pre-pass. That makes failover a pure consequence of the (future)
* failed-CDN constraint: when the primary's tracks are pruned during cooldown,
* the scope falls to the next CDN; when the primary recovers, it snaps back.
* Content steering, when it lands, reorders `cdnPriority` (pathway priority as a
* sort key).
*
* Lifecycle: `'presentation-unresolved'` `'presentation-resolved'`, mirroring
* `setupTrackSwitching`. The resolved state owns the signal; its entry-returned
* cleanup clears it on exit (canonical cleanup-binds-to-setup per `reactors.md`).
*/
import { defineBehavior } from '../../core/composition/create-composition';
import { createMachineReactor } from '../../core/reactors/create-machine-reactor';
import { computed, peek, type ReadonlySignal, type Signal } from '../../core/signals/primitives';
import { isResolvedPresentation, type MaybeResolvedPresentation } from '../../media/types';
import { getOrderedCdnIds } from '../../media/utils/cdn';
export interface ResolveCdnPriorityState {
presentation?: MaybeResolvedPresentation;
cdnPriority?: string[];
}
const samePriority = (a: string[] | undefined, b: string[]): boolean =>
!!a && a.length === b.length && a.every((cdn, i) => cdn === b[i]);
/**
* Manage `cdnPriority`: publish the manifest-ordered CDN list on src load, clear
* on src unload.
*
* @example
* const reactor = resolveCdnPriority.setup({ state });
*/
export const resolveCdnPriority = defineBehavior({
stateKeys: ['presentation', 'cdnPriority'],
contextKeys: [],
setup: ({
state,
}: {
state: {
presentation: ReadonlySignal<ResolveCdnPriorityState['presentation']>;
cdnPriority: Signal<ResolveCdnPriorityState['cdnPriority']>;
};
}) => {
const derivedStateSignal = computed(() =>
isResolvedPresentation(state.presentation.get())
? ('presentation-resolved' as const)
: ('presentation-unresolved' as const)
);
return createMachineReactor({
initial: 'presentation-unresolved',
monitor: () => derivedStateSignal.get(),
states: {
'presentation-unresolved': {},
'presentation-resolved': {
// Cleanup-binds-to-setup: the CDN priority list is valid for exactly
// 'presentation-resolved'. Clear fires on exit (src unload + destroy).
entry: () => () => state.cdnPriority.set(undefined),
effects: [
() => {
const presentation = state.presentation.get();
if (!isResolvedPresentation(presentation)) return;
const next = getOrderedCdnIds(presentation);
// Skip the write when the CDN set is unchanged — a live reload swaps
// in a new presentation object with the same hosts, and re-setting a
// fresh array would re-fire the scope for an identical result.
// `peek` reads without subscribing, so this never self-triggers.
if (!samePriority(peek(state.cdnPriority), next)) state.cdnPriority.set(next);
},
],
},
},
});
},
});
@@ -0,0 +1,147 @@
import { describe, expect, it } from 'vitest';
import type { StateSignals } from '../../../core/composition/create-composition';
import { signal } from '../../../core/signals/primitives';
import type { MaybeResolvedPresentation, PartiallyResolvedVideoTrack, Presentation } from '../../../media/types';
import { type ResolveCdnPriorityState, resolveCdnPriority } from '../resolve-cdn-priority';
function makeState(initial: Partial<ResolveCdnPriorityState> = {}): StateSignals<ResolveCdnPriorityState> {
return {
presentation: signal<MaybeResolvedPresentation | undefined>(initial.presentation),
cdnPriority: signal<string[] | undefined>(initial.cdnPriority),
};
}
const videoTrack = (id: string, url: string): PartiallyResolvedVideoTrack => ({
type: 'video',
codecs: [],
id,
url,
bandwidth: 2_000_000,
mimeType: 'video/mp4',
});
const presentationWith = (urls: string[], id = 'pres-1'): Presentation =>
({
id,
url: 'https://cdn-a.example.com/master.m3u8',
selectionSets: [
{
id: 'video-set',
type: 'video' as const,
switchingSets: [
{ id: 'video-switching', type: 'video' as const, tracks: urls.map((u, i) => videoTrack(`v${i}`, u)) },
],
},
],
}) as Presentation;
// A redundant-streams presentation: each rendition duplicated across CDNs, in
// manifest priority order (cdn-a first).
const redundant = (id = 'pres-1'): Presentation =>
presentationWith(
[
'https://cdn-a.example.com/720p.m3u8',
'https://cdn-b.example.com/720p.m3u8',
'https://cdn-a.example.com/1080p.m3u8',
'https://cdn-b.example.com/1080p.m3u8',
],
id
);
const flush = () => Promise.resolve().then(() => Promise.resolve());
describe('resolveCdnPriority', () => {
it('does nothing without a presentation', async () => {
const state = makeState();
const reactor = resolveCdnPriority.setup({ state });
await flush();
expect(state.cdnPriority.get()).toBeUndefined();
reactor.destroy();
});
it('publishes the manifest-ordered CDN list on src load', async () => {
const state = makeState({ presentation: redundant() });
const reactor = resolveCdnPriority.setup({ state });
await flush();
expect(state.cdnPriority.get()).toEqual(['https://cdn-a.example.com', 'https://cdn-b.example.com']);
reactor.destroy();
});
it('publishes a single-entry list for a non-redundant source', async () => {
const state = makeState({ presentation: presentationWith(['https://cdn-a.example.com/720p.m3u8']) });
const reactor = resolveCdnPriority.setup({ state });
await flush();
expect(state.cdnPriority.get()).toEqual(['https://cdn-a.example.com']);
reactor.destroy();
});
it('does not re-set the list when a resolved swap keeps the same CDNs', async () => {
const state = makeState({ presentation: redundant() });
const reactor = resolveCdnPriority.setup({ state });
await flush();
const first = state.cdnPriority.get();
// Live-reload-style swap: new presentation object, same hosts.
state.presentation.set(redundant('pres-2'));
await flush();
// Same reference — the no-churn guard skipped the write.
expect(state.cdnPriority.get()).toBe(first);
reactor.destroy();
});
it('updates the list when a resolved swap changes the CDN order', async () => {
const state = makeState({ presentation: redundant() });
const reactor = resolveCdnPriority.setup({ state });
await flush();
expect(state.cdnPriority.get()).toEqual(['https://cdn-a.example.com', 'https://cdn-b.example.com']);
state.presentation.set(
presentationWith(['https://cdn-b.example.com/720p.m3u8', 'https://cdn-a.example.com/720p.m3u8'], 'pres-2')
);
await flush();
expect(state.cdnPriority.get()).toEqual(['https://cdn-b.example.com', 'https://cdn-a.example.com']);
reactor.destroy();
});
it('clears cdnPriority on src unload', async () => {
const state = makeState({ presentation: redundant() });
const reactor = resolveCdnPriority.setup({ state });
await flush();
expect(state.cdnPriority.get()).toBeDefined();
state.presentation.set(undefined);
await flush();
expect(state.cdnPriority.get()).toBeUndefined();
reactor.destroy();
});
it('clears cdnPriority on destroy', async () => {
const state = makeState({ presentation: redundant() });
const reactor = resolveCdnPriority.setup({ state });
await flush();
expect(state.cdnPriority.get()).toBeDefined();
reactor.destroy();
expect(state.cdnPriority.get()).toBeUndefined();
});
it('re-publishes after a src reset (undefined → new resolved)', async () => {
const state = makeState({ presentation: redundant() });
const reactor = resolveCdnPriority.setup({ state });
await flush();
expect(state.cdnPriority.get()).toEqual(['https://cdn-a.example.com', 'https://cdn-b.example.com']);
state.presentation.set(undefined);
await flush();
expect(state.cdnPriority.get()).toBeUndefined();
state.presentation.set(redundant('pres-2'));
await flush();
expect(state.cdnPriority.get()).toEqual(['https://cdn-a.example.com', 'https://cdn-b.example.com']);
reactor.destroy();
});
});
@@ -734,3 +734,132 @@ describe('applyRules', () => {
expect(received).toEqual([all, deps]);
});
});
// ============================================================================
// preferActiveCdn — active-CDN scope (shared by video + audio)
// ============================================================================
describe('preferActiveCdn (active-CDN scope)', () => {
const cdnVideoTrack = (id: string, host: string, bandwidth: number): PartiallyResolvedVideoTrack => ({
type: 'video',
codecs: [],
id,
url: `https://${host}/${id}.m3u8`,
bandwidth,
mimeType: 'video/mp4',
});
// Two renditions duplicated across cdn-a (listed first) and cdn-b.
const multiCdn = () =>
createPresentation([
cdnVideoTrack('720p-a', 'cdn-a.example.com', 2_400_000),
cdnVideoTrack('720p-b', 'cdn-b.example.com', 2_400_000),
cdnVideoTrack('1080p-a', 'cdn-a.example.com', 4_800_000),
cdnVideoTrack('1080p-b', 'cdn-b.example.com', 4_800_000),
]);
// Bandwidth high enough that 1080p fits, so the pick is the highest rendition
// on whichever CDN the scope leaves standing.
const makeCdnState = (cdnPriority?: string[]) => ({
presentation: signal<MaybeResolvedPresentation | undefined>(multiCdn()),
bandwidthState: signal<BandwidthState | undefined>(createBandwidthState(10_000_000)),
selectedVideoTrackId: signal<string | undefined>(undefined),
userVideoTrackSelection: signal<Partial<VideoTrack> | undefined>(undefined),
cdnPriority: signal<string[] | undefined>(cdnPriority),
});
it('narrows the pick to the highest-priority CDN, overriding manifest track order', async () => {
// cdn-a's 1080p is listed first in the tracks, but cdn-b is first in `cdnPriority`,
// so the scope picks cdn-b. (Without the scope abr would pick 1080p-a.)
const state = makeCdnState(['https://cdn-b.example.com', 'https://cdn-a.example.com']);
const reactor = switchVideoTrack.setup({ state });
await flush();
expect(state.selectedVideoTrackId.get()).toBe('1080p-b');
reactor.destroy();
});
it('keeps the pick on the primary CDN when it is first in the list', async () => {
const state = makeCdnState(['https://cdn-a.example.com', 'https://cdn-b.example.com']);
const reactor = switchVideoTrack.setup({ state });
await flush();
expect(state.selectedVideoTrackId.get()).toBe('1080p-a');
reactor.destroy();
});
it('falls through to the next CDN when the first has no surviving tracks', async () => {
// cdn-z has no tracks (as if pruned by a failover constraint), so the scope
// skips it and narrows to cdn-a.
const state = makeCdnState(['https://cdn-z.example.com', 'https://cdn-a.example.com']);
const reactor = switchVideoTrack.setup({ state });
await flush();
expect(state.selectedVideoTrackId.get()).toBe('1080p-a');
reactor.destroy();
});
it('falls through to all CDNs when no list entry matches any track', async () => {
const state = makeCdnState(['https://cdn-z.example.com']);
const reactor = switchVideoTrack.setup({ state });
await flush();
expect(state.selectedVideoTrackId.get()).toBe('1080p-a');
reactor.destroy();
});
it('is a no-op when no cdnPriority list is present', async () => {
const state = makeCdnState(undefined);
const reactor = switchVideoTrack.setup({ state });
await flush();
expect(state.selectedVideoTrackId.get()).toBe('1080p-a');
reactor.destroy();
});
it('applies the scope when cdnPriority arrives after the first pick (composition-order independence)', async () => {
// Guards against the pick depending on `resolveCdnPriority` being composed
// *before* `switchVideoTrack`. The worst case — resolveCdnPriority last — is
// equivalent to cdnPriority being written after switch*'s first pick. The
// scope subscribes to cdnPriority even while it's undefined, so a late write
// must re-fire and correct the pick.
const state = makeCdnState(undefined);
const reactor = switchVideoTrack.setup({ state });
await flush();
// No cdnPriority yet → scope is a no-op → ranker picks the manifest head.
expect(state.selectedVideoTrackId.get()).toBe('1080p-a');
state.cdnPriority.set(['https://cdn-b.example.com', 'https://cdn-a.example.com']);
await flush();
expect(state.selectedVideoTrackId.get()).toBe('1080p-b');
reactor.destroy();
});
it('re-picks when the CDN order changes while staying resolved (steering/failover seam)', async () => {
const state = makeCdnState(['https://cdn-a.example.com', 'https://cdn-b.example.com']);
const reactor = switchVideoTrack.setup({ state });
await flush();
expect(state.selectedVideoTrackId.get()).toBe('1080p-a');
state.cdnPriority.set(['https://cdn-b.example.com', 'https://cdn-a.example.com']);
await flush();
expect(state.selectedVideoTrackId.get()).toBe('1080p-b');
reactor.destroy();
});
it('applies the same scope to the audio chain (cross-type CDN coherence)', async () => {
const state = {
presentation: signal<MaybeResolvedPresentation | undefined>(
createAudioPresentation([
makeAudioTrack('aud-a', { url: 'https://cdn-a.example.com/aud.m3u8' }),
makeAudioTrack('aud-b', { url: 'https://cdn-b.example.com/aud.m3u8' }),
])
),
bandwidthState: signal<BandwidthState | undefined>(undefined),
selectedAudioTrackId: signal<string | undefined>(undefined),
userAudioTrackSelection: signal<Partial<AudioTrack> | undefined>(undefined),
cdnPriority: signal<string[] | undefined>(['https://cdn-b.example.com', 'https://cdn-a.example.com']),
};
const reactor = switchAudioTrack.setup({ state });
await flush();
expect(state.selectedAudioTrackId.get()).toBe('aud-b');
reactor.destroy();
});
});
@@ -7,11 +7,15 @@
* Selection runs a small ordered chain of rules over the candidate tracks
* (`applyRules`). Each rule narrows or reorders the list and reads the signals
* it needs at apply time, so the effect subscribes to exactly what the applied
* rules consult. Today the chain is two rules, most authoritative first:
* rules consult. Today the chain is three rules, most authoritative first:
*
* 1. **user intent** a soft filter on `user*TrackSelection`: narrow to the
* partial-track match; an empty match falls through to the full set.
* 2. **ranking** the terminal sort: `rankByBandwidth`, shared by video and
* 2. **active CDN** a soft filter on `cdnPriority` (`preferActiveCdn`):
* narrow to the highest-priority CDN that still has tracks; an empty match
* falls through. Shared by video and audio, so every type stays on one CDN
* (`resolveCdnPriority` owns the list). No-op for non-redundant sources.
* 3. **ranking** the terminal sort: `rankByBandwidth`, shared by video and
* audio. Fitting tracks (within the throughput threshold) first, highest
* bitrate first; over-throughput tracks after, least-over first. Hysteresis
* via boosting the current track's sort weight by `upgradeMargin`.
@@ -28,13 +32,15 @@
* The pick is the head of the chain's result (`applyRules(...)[0]`). Each
* variant supplies its **rule chain** via config; `setupTrackSwitching` owns
* only the lifecycle and runs whatever chain it's given. Both variants today run
* `[filterByUserSelection, rankByBandwidth]`; `switchVideoTrack` also accepts ABR
* tuning config, `switchAudioTrack` takes none.
* `[filterByUserSelection, preferActiveCdn, rankByBandwidth]`; `switchVideoTrack`
* also accepts ABR tuning config, `switchAudioTrack` takes none.
*
* Deferred (not yet in the chain): a hard-constraints pre-pass (capability
* probing, CDN failover) gating the candidate set, and audio's preferred-
* language / default-track selection as standing soft-filter rules previously
* the empty-slot picker, dropped in the move to the rule chain.
* probing, CDN *failover* excluding a failed CDN's tracks during cooldown)
* gating the candidate set, and audio's preferred-language / default-track
* selection as standing soft-filter rules previously the empty-slot picker,
* dropped in the move to the rule chain. (The active-CDN *scope* above is the
* sticky-pick half of multi-CDN; the failed-CDN constraint is the failover half.)
*/
import { type AnySlotMap, defineBehavior } from '../../core/composition/create-composition';
@@ -50,6 +56,7 @@ import {
type PartiallyResolvedVideoTrack,
type VideoTrack,
} from '../../media/types';
import { getCdnId } from '../../media/utils/cdn';
import { getTracksByType } from '../../media/utils/tracks';
import type { BandwidthConfig, BandwidthState } from '../../network/bandwidth-estimator';
import { DEFAULT_BANDWIDTH_CONFIG, getBandwidthEstimate } from '../../network/bandwidth-estimator';
@@ -178,11 +185,14 @@ export function applyRules<T, State, Context, Config>(
// ============================================================================
/**
* Minimum candidate-track shape consumed by the helper. `bandwidth` feeds the
* ranker's throughput sort; `width`/`height` are the equal-bitrate tie-break
* (absent on audio, so audio candidates area-compare equal).
* Minimum candidate-track shape consumed by the helper and its rules: an `id`
* (the pick), a `url` (the active-CDN scope derives the CDN from it), an
* optional `bandwidth` (the ranker's throughput sort), and optional
* `width`/`height` (the ranker's equal-bitrate tie-break absent on audio, so
* audio candidates area-compare equal). Every resolved/partially-resolved video
* track carries them all; audio tracks omit the dimensions.
*/
type SwitchableTrack = { id: string; bandwidth?: number; width?: number; height?: number };
type SwitchableTrack = { id: string; url: string; bandwidth?: number; width?: number; height?: number };
type SelectionKey = 'selectedVideoTrackId' | 'selectedAudioTrackId';
type UserSelectionKey = 'userVideoTrackSelection' | 'userAudioTrackSelection';
@@ -264,6 +274,17 @@ type BandwidthRankerStateMap<S extends SelectionKey> = TrackSwitchingStateMap<S>
type BandwidthRankerConfig<S extends SelectionKey, T extends SwitchableTrack> = TrackSwitchingConfig<S, T> &
SwitchVideoTrackConfig;
/**
* State the active-CDN scope reads: the lifecycle map plus an *optional*
* `cdnPriority` the manifest-ordered CDN list (most-preferred first). The
* signal exists only when the composition includes `resolveCdnPriority` (which
* materializes + owns it); the scope reads it defensively and passes through
* when it's absent (no CDN preference).
*/
type CdnScopeStateMap<S extends SelectionKey> = TrackSwitchingStateMap<S> & {
cdnPriority?: ReadonlySignal<string[] | undefined>;
};
type VideoTrackCandidate = PartiallyResolvedVideoTrack | VideoTrack;
type AudioTrackCandidate = PartiallyResolvedAudioTrack | AudioTrack;
@@ -289,6 +310,39 @@ function filterByUserSelection<S extends SelectionKey, U extends UserSelectionKe
return filter ? tracks.filter((track) => matchesPartialTrack(track, filter)) : tracks;
}
/**
* Active-CDN scope a soft filter, shared by video and audio. Narrows to the
* highest-priority CDN in `cdnPriority` (owned by `resolveCdnPriority`) that
* still has tracks, so every track type stays on one CDN. A redundant-streams
* source lists the same renditions on multiple hosts; this keeps the pick on one
* host rather than letting the ranker drift across them.
*
* "Active" is derived, not stored: constraints run before the rule chain, so a
* failed CDN's tracks are already pruned by the time this runs "first CDN with
* survivors" *is* the active CDN, and it falls through to the next on failover
* (and snaps back to the primary when it recovers). Content steering reorders
* `cdnPriority`; this rule just honors the order.
*
* Soft-filter semantics: passes through when there's no `cdnPriority` signal/value
* (no preference) or when nothing matches (`applyRules` skips an empty result).
* Non-redundant sources have one CDN, so the narrow is a no-op.
*
* The CDN-id derivation (`getCdnId`, origin-based) is hardcoded for now; a
* consumer-configurable derivation can move onto a rule config view later.
*/
function preferActiveCdn<S extends SelectionKey, T extends SwitchableTrack>(
tracks: readonly T[],
{ state }: SelectionRuleDeps<CdnScopeStateMap<S>, AnySlotMap, TrackSwitchingConfig<S, T>>
): readonly T[] {
const cdnPriority = state.cdnPriority?.get();
if (!cdnPriority?.length) return tracks;
for (const cdn of cdnPriority) {
const tracksUsingCdn = tracks.filter((track) => getCdnId(track.url) === cdn);
if (tracksUsingCdn.length) return tracksUsingCdn;
}
return tracks;
}
/**
* Bandwidth ranking the terminal sort, shared by video and audio. Orders by
* the throughput estimate: tracks within the bandwidth threshold first
@@ -470,7 +524,7 @@ export const switchVideoTrack = defineBehavior({
selectionKey: 'selectedVideoTrackId',
userSelectionKey: 'userVideoTrackSelection',
getTracks: (presentation) => getTracksByType(presentation, 'video') as readonly VideoTrackCandidate[],
rules: [filterByUserSelection, rankByBandwidth],
rules: [filterByUserSelection, preferActiveCdn, rankByBandwidth],
},
}),
});
@@ -503,7 +557,7 @@ export const switchAudioTrack = defineBehavior({
selectionKey: 'selectedAudioTrackId',
userSelectionKey: 'userAudioTrackSelection',
getTracks: (presentation) => getTracksByType(presentation, 'audio') as readonly AudioTrackCandidate[],
rules: [filterByUserSelection, rankByBandwidth],
rules: [filterByUserSelection, preferActiveCdn, rankByBandwidth],
},
}),
});
@@ -23,6 +23,7 @@ import { setupMediaSource } from '../../behaviors/dom/setup-mediasource';
import { trackCurrentTime } from '../../behaviors/dom/track-current-time';
import { trackLoadTriggers } from '../../behaviors/dom/track-load-triggers';
import { updateMediaSourceDuration } from '../../behaviors/dom/update-mediasource-duration';
import { resolveCdnPriority } from '../../behaviors/resolve-cdn-priority';
import { type ParsePresentation, resolvePresentation } from '../../behaviors/resolve-presentation';
import { resolveAudioTrack } from '../../behaviors/resolve-track';
import { syncPreload } from '../../behaviors/sync-preload';
@@ -51,6 +52,13 @@ export interface SimpleHlsAudioOnlyEngineState {
* Multi-language-audio Tier 2 programmatic-write path.
*/
userAudioTrackSelection?: Partial<AudioTrack>;
/**
* The CDNs the source is served from, in manifest priority order (mirrors
* HLS content steering's `PATHWAY-PRIORITY`). Owned by `resolveCdnPriority`,
* read by `track-switching`'s `preferActiveCdn` scope. Only meaningful for
* redundant-stream sources; a single-CDN source has one entry.
*/
cdnPriority?: string[];
currentTime?: number;
loadActivated?: boolean;
}
@@ -143,6 +151,17 @@ export function createHlsAudioOnlyEngine(
trackLoadTriggers,
resolvePresentation,
// Session-level CDN priority for redundant-stream sources. Owns
// `cdnPriority`; switchAudioTrack's preferActiveCdn scope reads it. No-op
// for single-CDN sources.
//
// With a single track type there's no cross-type coherence to enforce and
// the first pick is the primary CDN regardless, so composition order is
// not load-bearing here today. It earns its place for forward-consistency
// with the default engine and for future failover / steering, where the
// active CDN changes dynamically (and selection stays reactive either way).
resolveCdnPriority,
// Audio track selection — slot owner with filter reactivity.
// Mid-stream flush on language switch is handled in segment-loader's
// planTasks, not here.
@@ -35,6 +35,7 @@ import { syncTextTracks } from '../../behaviors/dom/sync-text-tracks';
import { trackCurrentTime } from '../../behaviors/dom/track-current-time';
import { trackLoadTriggers } from '../../behaviors/dom/track-load-triggers';
import { updateMediaSourceDuration } from '../../behaviors/dom/update-mediasource-duration';
import { resolveCdnPriority } from '../../behaviors/resolve-cdn-priority';
import { type ParsePresentation, resolvePresentation } from '../../behaviors/resolve-presentation';
import { resolveAudioTrack, resolveTextTrack, resolveVideoTrack } from '../../behaviors/resolve-track';
import { selectTextTrack } from '../../behaviors/select-tracks';
@@ -71,6 +72,16 @@ export interface SimpleHlsEngineState {
* when it changes. Multi-language-audio Tier 2 programmatic-write path.
*/
userAudioTrackSelection?: Partial<AudioTrack>;
/**
* The CDNs the source is served from (track-URL origins), in manifest
* priority order most-preferred first (mirrors HLS content steering's
* `PATHWAY-PRIORITY`). Owned by `resolveCdnPriority`, read by
* `track-switching`'s `preferActiveCdn` scope, which narrows to the
* highest-priority CDN with surviving tracks so video / audio / text stay on
* one host. Only meaningful for redundant-stream sources; a single-CDN source
* has one entry.
*/
cdnPriority?: string[];
currentTime?: number;
loadActivated?: boolean;
}
@@ -248,6 +259,22 @@ export function createSimpleHlsEngine(
trackLoadTriggers,
resolvePresentation,
// Session-level CDN priority for redundant-stream sources. Owns
// `cdnPriority`; `track-switching`'s preferActiveCdn scope reads it so
// every type stays on one CDN. No-op for single-CDN sources.
//
// Placed before switch* so `cdnPriority` is set before the first pick —
// but this ordering is only *mildly* load-bearing, not required for
// correctness. Selection is reactive: a late `cdnPriority` re-fires the
// pick and converges on the same result (see the late-arrival test in
// track-switching.test.ts). Order affects only a transient, and only for
// an *asymmetric* manifest (a type listing a non-primary CDN first):
// composing this after switch* would let that type fire one wasted
// media-playlist fetch to the wrong CDN before correcting. Symmetric
// redundant streams (the norm) never hit it — the first-listed CDN is
// already the primary we'd pick anyway.
resolveCdnPriority,
// Track selection (reads config for initial preferences).
// Video selection lives in switchVideoTrack (composed below);
// audio selection lives in switchAudioTrack (composed below) —
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { snapshot } from '../../../../core/signals/primitives';
import type { PartiallyResolvedAudioTrack, PartiallyResolvedVideoTrack, Presentation } from '../../../../media/types';
import { createSimpleHlsEngine } from '../engine';
// Mock appendSegment to succeed without real MP4 data
@@ -59,6 +60,7 @@ describe('createSimpleHlsEngine', () => {
// Everything else starts as `undefined` and behaviors write their
// own slots in response to inputs.
expect(snapshot(engine.state)).toEqual({
cdnPriority: undefined,
userVideoTrackSelection: undefined,
bandwidthState: {
fastEstimate: 0,
@@ -82,6 +84,132 @@ describe('createSimpleHlsEngine', () => {
engine.destroy();
});
it('publishes the CDN list and keeps the video selection on the primary (redundant-stream source)', async () => {
const flush = () => Promise.resolve().then(() => Promise.resolve());
const engine = createSimpleHlsEngine();
const videoTrack = (id: string, host: string): PartiallyResolvedVideoTrack => ({
type: 'video',
id,
codecs: [],
url: `https://${host}/${id}.m3u8`,
bandwidth: 2_400_000,
mimeType: 'video/mp4',
});
// Same rendition duplicated across cdn-a (manifest head) and cdn-b.
engine.state.presentation.set({
id: 'pres-1',
url: 'https://cdn-a.example.com/master.m3u8',
startTime: 0,
selectionSets: [
{
id: 'v',
type: 'video',
switchingSets: [
{
id: 'vs',
type: 'video',
tracks: [videoTrack('720p-a', 'cdn-a.example.com'), videoTrack('720p-b', 'cdn-b.example.com')],
},
],
},
],
} as Presentation);
await flush();
// resolveCdns publishes the manifest-ordered list; preferActiveCdn narrows
// the video pick to the primary (first-with-survivors) CDN.
expect(engine.state.cdnPriority.get()).toEqual(['https://cdn-a.example.com', 'https://cdn-b.example.com']);
expect(engine.state.selectedVideoTrackId.get()).toBe('720p-a');
// Reorder the CDN list (steering/override seam): the scope re-narrows and the
// selection follows to the other CDN's matching rendition.
engine.state.cdnPriority.set(['https://cdn-b.example.com', 'https://cdn-a.example.com']);
await flush();
expect(engine.state.selectedVideoTrackId.get()).toBe('720p-b');
engine.destroy();
});
it('keeps audio on the same CDN as video even when the audio rendition order differs', async () => {
// Order-effect guard: `resolveCdnPriority` derives the list from track order,
// so a same-ordered source can't distinguish "scope applied" from "scope is
// a no-op". This source is doubly adversarial to the desired result: the
// audio selection set comes BEFORE video in the manifest, and within it the
// audio renditions list cdn-b first. By raw parse order that would make
// cdn-b primary and pull everything onto cdn-b. `getOrderedCdnIds` instead
// visits video selection sets before audio, so `cdnPriority` is video-derived
// (cdn-a primary) *by guarantee*, and the scope pulls audio onto cdn-a —
// `aud-a`, NOT the parse-order `aud-b`. This pins the type-priority ordering,
// not a manifest/parse-order coincidence.
const flush = () => Promise.resolve().then(() => Promise.resolve());
const engine = createSimpleHlsEngine();
const videoTrack = (id: string, host: string): PartiallyResolvedVideoTrack => ({
type: 'video',
id,
codecs: [],
url: `https://${host}/${id}.m3u8`,
bandwidth: 2_400_000,
mimeType: 'video/mp4',
});
const audioTrack = (id: string, host: string): PartiallyResolvedAudioTrack => ({
type: 'audio',
id,
codecs: ['mp4a.40.2'],
url: `https://${host}/${id}.m3u8`,
bandwidth: 128_000,
mimeType: 'audio/mp4',
groupId: 'audio',
name: id,
sampleRate: 48_000,
channels: 2,
});
engine.state.presentation.set({
id: 'pres-asym',
url: 'https://cdn-a.example.com/master.m3u8',
startTime: 0,
// Audio selection set listed FIRST, cdn-b first within it — the reverse of
// the video order on both axes. Type-priority ordering must still put video
// (cdn-a) at the head of cdnPriority.
selectionSets: [
{
id: 'a',
type: 'audio',
switchingSets: [
{
id: 'as',
type: 'audio',
tracks: [audioTrack('aud-b', 'cdn-b.example.com'), audioTrack('aud-a', 'cdn-a.example.com')],
},
],
},
{
id: 'v',
type: 'video',
switchingSets: [
{
id: 'vs',
type: 'video',
tracks: [videoTrack('vid-a', 'cdn-a.example.com'), videoTrack('vid-b', 'cdn-b.example.com')],
},
],
},
],
} as Presentation);
await flush();
expect(engine.state.cdnPriority.get()).toEqual(['https://cdn-a.example.com', 'https://cdn-b.example.com']);
expect(engine.state.selectedVideoTrackId.get()).toBe('vid-a');
// The discriminator: audio is listed first and lists aud-b first, but the
// video-derived cdnPriority puts cdn-a first, so the scope picks aud-a.
expect(engine.state.selectedAudioTrackId.get()).toBe('aud-a');
engine.destroy();
});
it('allows patching state and owners from outside', async () => {
const engine = createSimpleHlsEngine();