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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
329 changes: 186 additions & 143 deletions internal/design/spf/features/multi-cdn-failover.md

Large diffs are not rendered by default.

38 changes: 28 additions & 10 deletions packages/spf/src/media/utils/cdn.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
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.
* Derive a stable grouping key for the CDN a URL is served from. Synchronous and
* pure (deliberately not a `resolve*` — no fetch). Consumers override the
* default via the engine's `getCdnId` config (e.g. to key on Mux's `cdn=` query
* param instead of the host); every CDN-identity site reads that same function
* so keys stay comparable across `cdnPriority`, `failedCdns`, and the
* track-switching constraint + scope.
*/
export type GetCdnId = (url: string) => string;

/**
* Default {@link GetCdnId}: 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.
*/
export function getCdnId(url: string): string {
try {
Expand All @@ -34,9 +40,11 @@ const CDN_TYPE_PRIORITY: Record<TrackType, number> = { video: 0, audio: 1, text:
*
* 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.
* this collapses them to the set of CDNs across every track type. The CDN-id
* derivation defaults to {@link getCdnId}; pass a consumer-configured `getId` to
* key on something other than origin.
*/
export function getOrderedCdnIds(presentation: MaybeResolvedPresentation): string[] {
export function getOrderedCdnIds(presentation: MaybeResolvedPresentation, getId: GetCdnId = getCdnId): string[] {
const seen = new Set<string>();
const ids: string[] = [];
// Stable sort keeps manifest order among same-type selection sets.
Expand All @@ -46,7 +54,7 @@ export function getOrderedCdnIds(presentation: MaybeResolvedPresentation): strin
for (const selectionSet of selectionSets) {
for (const switchingSet of selectionSet.switchingSets) {
for (const track of switchingSet.tracks) {
const id = getCdnId(track.url);
const id = getId(track.url);
if (seen.has(id)) continue;
seen.add(id);
ids.push(id);
Expand All @@ -55,3 +63,13 @@ export function getOrderedCdnIds(presentation: MaybeResolvedPresentation): strin
}
return ids;
}

/**
* Add a CDN id to a failed-CDN list, preserving order and ignoring duplicates.
* Idempotent: re-adding an already-present id returns the same array reference
* (so a no-op trip doesn't churn the `failedCdns` signal). The failover trip in
* `resolve-track` and the segment loaders feed this into `failedCdns` via `update`.
*/
export function addFailedCdn(failed: string[] | undefined, cdn: string): string[] {
return failed?.includes(cdn) ? failed : [...(failed ?? []), cdn];
}
21 changes: 20 additions & 1 deletion packages/spf/src/media/utils/tests/cdn.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import type { MaybeResolvedPresentation } from '../../types';
import { getCdnId, getOrderedCdnIds } from '../cdn';
import { addFailedCdn, getCdnId, getOrderedCdnIds } from '../cdn';

const presentationWith = (urlsByType: {
video?: string[];
Expand Down Expand Up @@ -119,3 +119,22 @@ describe('getOrderedCdnIds', () => {
expect(getOrderedCdnIds(presentation)).toEqual(['https://cdn-a.example.com', 'https://cdn-b.example.com']);
});
});

describe('addFailedCdn', () => {
it('appends to an undefined or empty list', () => {
expect(addFailedCdn(undefined, 'https://cdn-a.example.com')).toEqual(['https://cdn-a.example.com']);
expect(addFailedCdn([], 'https://cdn-a.example.com')).toEqual(['https://cdn-a.example.com']);
});

it('appends in order', () => {
expect(addFailedCdn(['https://cdn-a.example.com'], 'https://cdn-b.example.com')).toEqual([
'https://cdn-a.example.com',
'https://cdn-b.example.com',
]);
});

it('is idempotent — re-adding a present CDN returns the same array reference', () => {
const failed = ['https://cdn-a.example.com'];
expect(addFailedCdn(failed, 'https://cdn-a.example.com')).toBe(failed);
});
});
17 changes: 17 additions & 0 deletions packages/spf/src/network/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,23 @@ export function getResponseText(response: ResponseLike): Promise<string> {
return response.text();
}

/**
* Fetch a resource and resolve its text body — the text analog of
* {@link FetchBytes}. A non-OK status rejects, so HTTP failures surface as
* rejections that callers (and decorators like the failover tracker) handle
* uniformly with network errors.
*/
export type FetchText = (addressable: Resource, options?: RequestInit) => Promise<string>;

/** Default {@link FetchText}: fetch the resource, reject on non-OK, return text. */
export const fetchResolvableText: FetchText = async (addressable, options) => {
const response = await fetchResolvable(addressable, options);
if (!response.ok) {
throw new Error(`fetchResolvableText: ${response.status} ${response.statusText} for ${addressable.url}`);
}
return getResponseText(response);
};

/**
* Two-stage fetch helper: eagerly starts the HTTP request (TTFB is awaited),
* then returns a lazy iterable over the response body. Separating connection
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@ 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';
import { getCdnId as defaultGetCdnId, type GetCdnId, getOrderedCdnIds } from '../../media/utils/cdn';

export interface ResolveCdnPriorityState {
export interface DeriveCdnPriorityState {
presentation?: MaybeResolvedPresentation;
cdnPriority?: string[];
}
Expand All @@ -44,19 +44,22 @@ const samePriority = (a: string[] | undefined, b: string[]): boolean =>
* on src unload.
*
* @example
* const reactor = resolveCdnPriority.setup({ state });
* const reactor = deriveCdnPriority.setup({ state });
*/
export const resolveCdnPriority = defineBehavior({
export const deriveCdnPriority = defineBehavior({
stateKeys: ['presentation', 'cdnPriority'],
contextKeys: [],
setup: ({
state,
config = {},
}: {
state: {
presentation: ReadonlySignal<ResolveCdnPriorityState['presentation']>;
cdnPriority: Signal<ResolveCdnPriorityState['cdnPriority']>;
presentation: ReadonlySignal<DeriveCdnPriorityState['presentation']>;
cdnPriority: Signal<DeriveCdnPriorityState['cdnPriority']>;
};
config?: { getCdnId?: GetCdnId };
}) => {
const getCdnId = config.getCdnId ?? defaultGetCdnId;
const derivedStateSignal = computed(() =>
isResolvedPresentation(state.presentation.get())
? ('presentation-resolved' as const)
Expand All @@ -76,7 +79,7 @@ export const resolveCdnPriority = defineBehavior({
() => {
const presentation = state.presentation.get();
if (!isResolvedPresentation(presentation)) return;
const next = getOrderedCdnIds(presentation);
const next = getOrderedCdnIds(presentation, getCdnId);
// 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.
Expand Down
48 changes: 43 additions & 5 deletions packages/spf/src/playback/behaviors/dom/setup-buffer-actors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,10 @@
import { defineBehavior } from '../../../core/composition/create-composition';
import type { Reactor } from '../../../core/reactors/create-machine-reactor';
import { createMachineReactor } from '../../../core/reactors/create-machine-reactor';
import { computed, type ReadonlySignal, type Signal } from '../../../core/signals/primitives';
import { computed, type ReadonlySignal, type Signal, update } from '../../../core/signals/primitives';
import { buildMimeCodec, createSourceBuffer } from '../../../media/dom/mse/mediasource-setup';
import type { MaybeResolvedPresentation, PartiallyResolvedTrack } from '../../../media/types';
import { addFailedCdn, getCdnId as defaultGetCdnId, type GetCdnId } from '../../../media/utils/cdn';
import { getSelectedTrack, type TrackSelectionState } from '../../../media/utils/track-selection';
import { hasCodecs } from '../../../media/utils/tracks';
import type { BandwidthState } from '../../../network/bandwidth-estimator';
Expand Down Expand Up @@ -202,6 +203,43 @@ function setupBufferActors<K extends SelectedTrackKey, A extends BufferActorKey,
// Specialized exports — one per media type
// ============================================================================

/**
* Wrap a segment `FetchBytes` so a failed fetch trips the segment's CDN into
* `failedCdns` — the failover trip for segments, mirroring `resolve-track`'s
* `failoverFetch` for media playlists. Unlike playlists the base fetch is
* injected (per-type `trackedFetch` / `fetchStream`).
*
* `state` is typed as the behavior's map intersected with an *optional*
* `failedCdns` — the failover monitor owns that slot, so a behavior's narrow
* state is assignable here without declaring it (and the intersection shares
* keys, so it isn't a weak type). No-op when no monitor is composed.
*/
function failoverFetchBytes(
// `presentation` is a structural anchor: every buffer state has it, so the
// narrow behavior state is assignable and this isn't a weak type (TS2559) —
// only `failedCdns` is read.
state: {
presentation: ReadonlySignal<MaybeResolvedPresentation | undefined>;
failedCdns?: Signal<string[] | undefined>;
},
baseFetch: FetchBytes,
config: { getCdnId?: GetCdnId }
): FetchBytes {
const getCdnId = config.getCdnId ?? defaultGetCdnId;
return async (addressable, options) => {
try {
return await baseFetch(addressable, options);
} catch (error) {
// A failed segment fetch trips its CDN; an abort (track switch / teardown)
// doesn't. No-op when no failover monitor is composed (it owns the signal).
if (!options?.signal?.aborted && state.failedCdns) {
update(state.failedCdns, (cdns) => addFailedCdn(cdns, getCdnId(addressable.url)));
}
throw error;
}
};
}

/**
* Set up the video `SourceBufferActor` + `SegmentLoaderActor`. Fires
* when `mediaSource` is attached and the selected video track is
Expand All @@ -221,7 +259,7 @@ export const setupVideoBufferActors = defineBehavior({
bandwidthState: Signal<BufferActorsState['bandwidthState']>;
};
context: BufferActorsContextMap<'videoBufferActor', 'videoSegmentLoaderActor'>;
config?: object;
config?: SegmentLoaderActorConfig & { getCdnId?: GetCdnId };
}) => {
// Bandwidth-sampling fetch. The factory accumulates EWMA state
// internally; the callback bridges samples to engine state for ABR.
Expand All @@ -241,7 +279,7 @@ export const setupVideoBufferActors = defineBehavior({
return setupBufferActors({
state,
context,
config: { ...VIDEO_TYPE_CONFIG, fetch: trackedFetch, ...config },
config: { ...VIDEO_TYPE_CONFIG, fetch: failoverFetchBytes(state, trackedFetch, config), ...config },
});
},
});
Expand Down Expand Up @@ -274,11 +312,11 @@ export const setupAudioBufferActors = defineBehavior({
}: {
state: BufferActorsStateMap<'selectedAudioTrackId'>;
context: BufferActorsContextMap<'audioBufferActor', 'audioSegmentLoaderActor'>;
config?: object;
config?: SegmentLoaderActorConfig & { getCdnId?: GetCdnId };
}) =>
setupBufferActors({
state,
context,
config: { ...AUDIO_TYPE_CONFIG, fetch: fetchStream, ...config },
config: { ...AUDIO_TYPE_CONFIG, fetch: failoverFetchBytes(state, fetchStream, config), ...config },
}),
});
Loading
Loading