Skip to content

Commit 779086f

Browse files
cjpillsburyclaude
andcommitted
refactor(spf): rename resolveCdnPriority to deriveCdnPriority
`resolve` is reserved for resolvables (presentations, tracks). This behavior derives and owns the `cdnPriority` signal from the already- resolved presentation, so `derive` is the accurate verb. Mirrors the shape of `calculatePresentationDuration`. Renames the behavior export, the `ResolveCdnPriorityState` type, the module + test files, and all references across the HLS engines, track-switching, and the multi-cdn-failover feature doc. The `cdnPriority` signal/slot name is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent dced3de commit 779086f

8 files changed

Lines changed: 40 additions & 40 deletions

File tree

internal/design/spf/features/multi-cdn-failover.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ ambiguity reflects that split.
4141
## Status
4242

4343
- **Composition:** both sub-features are implemented in
44-
`createSimpleHlsEngine` and the audio-only engine. `resolveCdnPriority`
44+
`createSimpleHlsEngine` and the audio-only engine. `deriveCdnPriority`
4545
owns the `cdnPriority` signal (manifest-ordered CDN list); the
4646
`preferActiveCdn` scope rule narrows candidates to the highest-priority CDN
4747
with surviving tracks (shared by the video + audio chains). For failover:
@@ -96,7 +96,7 @@ tracks, plus a fetch decorator that records failures into `failedCdns`.
9696

9797
| Phase | Sub-feature | Kind | What | State |
9898
|---|---|---|---|---|
99-
| 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** |
99+
| Sticky CDN pick | 1 | scope | `deriveCdnPriority` 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** |
100100
| Constraints pre-pass | 2 || `applyConstraints` + the `constraints` config slot in `setupTrackSwitching` (the hard-filter pre-pass that runs before the rule chain). Reusable by capability-probing | **Implemented** |
101101
| Failed-CDN constraint | 2 | constraint | `excludeFailedCdns` prunes tracks whose CDN ∈ `failedCdns`; the scope falls to the next `cdnPriority` entry and snaps back on recovery | **Implemented** |
102102
| Per-CDN failure tracking | 2 || **Site-adds, behavior-expires**: fetch sites trip a CDN into `failedCdns` on a failed fetch (`failoverFetch` / `failoverFetchBytes`); `setupFailoverMonitor` expires it after a cooldown. Self-contained (trip-on-first-failure + cooldown), not a `network-resilience` circuit-breaker | **Implemented** |
@@ -139,7 +139,7 @@ tracks, plus a fetch decorator that records failures into `failedCdns`.
139139
video and audio chains reference the *same* `preferActiveCdn` definition
140140
reading the *same* list, so they agree on the CDN even if their per-type
141141
track arrays differ. (The doc's earlier per-rendition lean is superseded.)
142-
- **`cdnPriority` writer composition.** `resolveCdnPriority` is the sole
142+
- **`cdnPriority` writer composition.** `deriveCdnPriority` is the sole
143143
writer today (publishes the manifest order). Failover needs no second
144144
writer — the failed-CDN constraint prunes tracks and the scope re-derives
145145
the active CDN. Content-steering would *reorder* `cdnPriority` (still a
@@ -157,7 +157,7 @@ tracks, plus a fetch decorator that records failures into `failedCdns`.
157157
terminal "everything pruned" state — today an all-CDNs-failed candidate set
158158
is empty and the prior pick is left in place (see *Follow-up candidates*).
159159
- **Live + multi-CDN.** During live playback the reload loop re-resolves
160-
the presentation; `resolveCdnPriority` re-publishes only when the CDN set
160+
the presentation; `deriveCdnPriority` re-publishes only when the CDN set
161161
changes (idempotent for a stable manifest). Cross-feature with
162162
[live-stream-support](./live-stream-support.md) (not yet implemented).
163163
- **Failover state is per-source.** Both `cdnPriority` and `failedCdns` tear
@@ -224,7 +224,7 @@ tracking as a future effort:
224224
- **`media/utils/cdn.ts`**`getCdnId(url)` (origin-based default) + the
225225
`GetCdnId` type; `getOrderedCdnIds(presentation, getCdnId?)`;
226226
`addFailedCdn(failed, cdn)` (pure, idempotent dedup-append).
227-
- **`playback/behaviors/resolve-cdn-priority.ts`**`resolveCdnPriority` owns
227+
- **`playback/behaviors/derive-cdn-priority.ts`**`deriveCdnPriority` owns
228228
`cdnPriority` (publishes `getOrderedCdnIds` on resolve, skips unchanged
229229
writes, clears on exit).
230230
- **`playback/behaviors/setup-failover-monitor.ts`**`setupFailoverMonitor`
@@ -240,13 +240,13 @@ tracking as a future effort:
240240
`CdnRuleConfig` view that carries `getCdnId`); `applyConstraints` pre-pass +
241241
`constraints` config slot; `SwitchableTrack` gains `url`.
242242
- **`playback/engines/hls/engine.ts` + `engine-audio-only.ts`**
243-
`resolveCdnPriority` + `setupFailoverMonitor` composed after
243+
`deriveCdnPriority` + `setupFailoverMonitor` composed after
244244
`resolvePresentation`; `failover?` + `getCdnId?` engine config; `cdnPriority?`
245245
+ `failedCdns?` engine state.
246246
- **`network/fetch.ts`**`FetchText` type + `fetchResolvableText` default
247247
(fetch → reject on non-OK → text), the text analog of `FetchBytes`.
248248

249-
State signals: `cdnPriority?: string[]` (owned by `resolveCdnPriority`) and
249+
State signals: `cdnPriority?: string[]` (owned by `deriveCdnPriority`) and
250250
`failedCdns?: string[]` (owned by `setupFailoverMonitor`; tripped by the fetch
251251
sites, read by the `excludeFailedCdns` constraint).
252252

@@ -255,7 +255,7 @@ sites, read by the `excludeFailedCdns` constraint).
255255
- `media/utils/tests/cdn.test.ts``getCdnId` (origin; same/different host;
256256
scheme+port; unparseable fallback); `getOrderedCdnIds` (order; dedupe; single;
257257
unresolved → `[]`); `addFailedCdn` (append; order; idempotent same-reference).
258-
- `playback/behaviors/tests/resolve-cdn-priority.test.ts` — publishes the
258+
- `playback/behaviors/tests/derive-cdn-priority.test.ts` — publishes the
259259
manifest-ordered list; single-CDN; skips the write on a same-CDN swap; updates
260260
on reorder; clears on unload/destroy; re-publishes after reset.
261261
- `playback/behaviors/tests/setup-failover-monitor.test.ts` — a tripped CDN is

packages/spf/src/playback/behaviors/resolve-cdn-priority.ts renamed to packages/spf/src/playback/behaviors/derive-cdn-priority.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import { computed, peek, type ReadonlySignal, type Signal } from '../../core/sig
3131
import { isResolvedPresentation, type MaybeResolvedPresentation } from '../../media/types';
3232
import { getCdnId as defaultGetCdnId, type GetCdnId, getOrderedCdnIds } from '../../media/utils/cdn';
3333

34-
export interface ResolveCdnPriorityState {
34+
export interface DeriveCdnPriorityState {
3535
presentation?: MaybeResolvedPresentation;
3636
cdnPriority?: string[];
3737
}
@@ -44,18 +44,18 @@ const samePriority = (a: string[] | undefined, b: string[]): boolean =>
4444
* on src unload.
4545
*
4646
* @example
47-
* const reactor = resolveCdnPriority.setup({ state });
47+
* const reactor = deriveCdnPriority.setup({ state });
4848
*/
49-
export const resolveCdnPriority = defineBehavior({
49+
export const deriveCdnPriority = defineBehavior({
5050
stateKeys: ['presentation', 'cdnPriority'],
5151
contextKeys: [],
5252
setup: ({
5353
state,
5454
config = {},
5555
}: {
5656
state: {
57-
presentation: ReadonlySignal<ResolveCdnPriorityState['presentation']>;
58-
cdnPriority: Signal<ResolveCdnPriorityState['cdnPriority']>;
57+
presentation: ReadonlySignal<DeriveCdnPriorityState['presentation']>;
58+
cdnPriority: Signal<DeriveCdnPriorityState['cdnPriority']>;
5959
};
6060
config?: { getCdnId?: GetCdnId };
6161
}) => {

packages/spf/src/playback/behaviors/tests/resolve-cdn-priority.test.ts renamed to packages/spf/src/playback/behaviors/tests/derive-cdn-priority.test.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@ import { describe, expect, it } from 'vitest';
22
import type { StateSignals } from '../../../core/composition/create-composition';
33
import { signal } from '../../../core/signals/primitives';
44
import type { MaybeResolvedPresentation, PartiallyResolvedVideoTrack, Presentation } from '../../../media/types';
5-
import { type ResolveCdnPriorityState, resolveCdnPriority } from '../resolve-cdn-priority';
5+
import { type DeriveCdnPriorityState, deriveCdnPriority } from '../derive-cdn-priority';
66

7-
function makeState(initial: Partial<ResolveCdnPriorityState> = {}): StateSignals<ResolveCdnPriorityState> {
7+
function makeState(initial: Partial<DeriveCdnPriorityState> = {}): StateSignals<DeriveCdnPriorityState> {
88
return {
99
presentation: signal<MaybeResolvedPresentation | undefined>(initial.presentation),
1010
cdnPriority: signal<string[] | undefined>(initial.cdnPriority),
@@ -50,34 +50,34 @@ const redundant = (id = 'pres-1'): Presentation =>
5050

5151
const flush = () => Promise.resolve().then(() => Promise.resolve());
5252

53-
describe('resolveCdnPriority', () => {
53+
describe('deriveCdnPriority', () => {
5454
it('does nothing without a presentation', async () => {
5555
const state = makeState();
56-
const reactor = resolveCdnPriority.setup({ state });
56+
const reactor = deriveCdnPriority.setup({ state });
5757
await flush();
5858
expect(state.cdnPriority.get()).toBeUndefined();
5959
reactor.destroy();
6060
});
6161

6262
it('publishes the manifest-ordered CDN list on src load', async () => {
6363
const state = makeState({ presentation: redundant() });
64-
const reactor = resolveCdnPriority.setup({ state });
64+
const reactor = deriveCdnPriority.setup({ state });
6565
await flush();
6666
expect(state.cdnPriority.get()).toEqual(['https://cdn-a.example.com', 'https://cdn-b.example.com']);
6767
reactor.destroy();
6868
});
6969

7070
it('publishes a single-entry list for a non-redundant source', async () => {
7171
const state = makeState({ presentation: presentationWith(['https://cdn-a.example.com/720p.m3u8']) });
72-
const reactor = resolveCdnPriority.setup({ state });
72+
const reactor = deriveCdnPriority.setup({ state });
7373
await flush();
7474
expect(state.cdnPriority.get()).toEqual(['https://cdn-a.example.com']);
7575
reactor.destroy();
7676
});
7777

7878
it('does not re-set the list when a resolved swap keeps the same CDNs', async () => {
7979
const state = makeState({ presentation: redundant() });
80-
const reactor = resolveCdnPriority.setup({ state });
80+
const reactor = deriveCdnPriority.setup({ state });
8181
await flush();
8282
const first = state.cdnPriority.get();
8383

@@ -92,7 +92,7 @@ describe('resolveCdnPriority', () => {
9292

9393
it('updates the list when a resolved swap changes the CDN order', async () => {
9494
const state = makeState({ presentation: redundant() });
95-
const reactor = resolveCdnPriority.setup({ state });
95+
const reactor = deriveCdnPriority.setup({ state });
9696
await flush();
9797
expect(state.cdnPriority.get()).toEqual(['https://cdn-a.example.com', 'https://cdn-b.example.com']);
9898

@@ -107,7 +107,7 @@ describe('resolveCdnPriority', () => {
107107

108108
it('clears cdnPriority on src unload', async () => {
109109
const state = makeState({ presentation: redundant() });
110-
const reactor = resolveCdnPriority.setup({ state });
110+
const reactor = deriveCdnPriority.setup({ state });
111111
await flush();
112112
expect(state.cdnPriority.get()).toBeDefined();
113113

@@ -120,7 +120,7 @@ describe('resolveCdnPriority', () => {
120120

121121
it('clears cdnPriority on destroy', async () => {
122122
const state = makeState({ presentation: redundant() });
123-
const reactor = resolveCdnPriority.setup({ state });
123+
const reactor = deriveCdnPriority.setup({ state });
124124
await flush();
125125
expect(state.cdnPriority.get()).toBeDefined();
126126

@@ -130,7 +130,7 @@ describe('resolveCdnPriority', () => {
130130

131131
it('re-publishes after a src reset (undefined → new resolved)', async () => {
132132
const state = makeState({ presentation: redundant() });
133-
const reactor = resolveCdnPriority.setup({ state });
133+
const reactor = deriveCdnPriority.setup({ state });
134134
await flush();
135135
expect(state.cdnPriority.get()).toEqual(['https://cdn-a.example.com', 'https://cdn-b.example.com']);
136136

packages/spf/src/playback/behaviors/tests/track-switching.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -851,8 +851,8 @@ describe('preferActiveCdn (active-CDN scope)', () => {
851851
});
852852

853853
it('applies the scope when cdnPriority arrives after the first pick (composition-order independence)', async () => {
854-
// Guards against the pick depending on `resolveCdnPriority` being composed
855-
// *before* `switchVideoTrack`. The worst case — resolveCdnPriority last — is
854+
// Guards against the pick depending on `deriveCdnPriority` being composed
855+
// *before* `switchVideoTrack`. The worst case — deriveCdnPriority last — is
856856
// equivalent to cdnPriority being written after switch*'s first pick. The
857857
// scope subscribes to cdnPriority even while it's undefined, so a late write
858858
// must re-fire and correct the pick.

packages/spf/src/playback/behaviors/track-switching.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
* 2. **active CDN** — a soft filter on `cdnPriority` (`preferActiveCdn`):
1818
* narrow to the highest-priority CDN that still has tracks; an empty match
1919
* falls through. Shared by video and audio, so every type stays on one CDN
20-
* (`resolveCdnPriority` owns the list). No-op for non-redundant sources.
20+
* (`deriveCdnPriority` owns the list). No-op for non-redundant sources.
2121
* 3. **ranking** — the terminal sort: `rankByBandwidth`, shared by video and
2222
* audio. Fitting tracks (within the throughput threshold) first, highest
2323
* bitrate first; over-throughput tracks after, least-over first. Hysteresis
@@ -310,7 +310,7 @@ type BandwidthRankerConfig<S extends SelectionKey, T extends SwitchableTrack> =
310310
/**
311311
* State the active-CDN scope reads: the lifecycle map plus an *optional*
312312
* `cdnPriority` — the manifest-ordered CDN list (most-preferred first). The
313-
* signal exists only when the composition includes `resolveCdnPriority` (which
313+
* signal exists only when the composition includes `deriveCdnPriority` (which
314314
* materializes + owns it); the scope reads it defensively and passes through
315315
* when it's absent (no CDN preference).
316316
*/
@@ -331,7 +331,7 @@ type CdnConstraintStateMap<S extends SelectionKey> = TrackSwitchingStateMap<S> &
331331
/**
332332
* Config the CDN rules read: the base config plus an *optional* `getCdnId`
333333
* override. Both `excludeFailedCdns` and `preferActiveCdn` derive a track's CDN
334-
* from its URL; the override must be the *same* one `resolveCdnPriority` and the
334+
* from its URL; the override must be the *same* one `deriveCdnPriority` and the
335335
* failover trip use, or the keys stop matching. Optional → defaults to the
336336
* origin-based `getCdnId`, so the base config (without it) stays assignable.
337337
*/
@@ -389,7 +389,7 @@ function excludeFailedCdns<S extends SelectionKey, T extends SwitchableTrack>(
389389

390390
/**
391391
* Active-CDN scope — a soft filter, shared by video and audio. Narrows to the
392-
* highest-priority CDN in `cdnPriority` (owned by `resolveCdnPriority`) that
392+
* highest-priority CDN in `cdnPriority` (owned by `deriveCdnPriority`) that
393393
* still has tracks, so every track type stays on one CDN. A redundant-streams
394394
* source lists the same renditions on multiple hosts; this keeps the pick on one
395395
* host rather than letting the ranker drift across them.
@@ -405,7 +405,7 @@ function excludeFailedCdns<S extends SelectionKey, T extends SwitchableTrack>(
405405
* Non-redundant sources have one CDN, so the narrow is a no-op.
406406
*
407407
* The CDN-id derivation defaults to origin-based `getCdnId`, overridable via the
408-
* `getCdnId` config — it must match the one `resolveCdnPriority` used to build
408+
* `getCdnId` config — it must match the one `deriveCdnPriority` used to build
409409
* `cdnPriority`, or no track's CDN would ever equal an entry.
410410
*/
411411
function preferActiveCdn<S extends SelectionKey, T extends SwitchableTrack>(

packages/spf/src/playback/engines/hls/engine-audio-only.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,14 @@ import {
1717
calculatePresentationDuration,
1818
type PresentationDurationResolver,
1919
} from '../../behaviors/calculate-presentation-duration';
20+
import { deriveCdnPriority } from '../../behaviors/derive-cdn-priority';
2021
import { endOfStream } from '../../behaviors/dom/end-of-stream';
2122
import { loadAudioSegments } from '../../behaviors/dom/load-segments';
2223
import { setupAudioBufferActors } from '../../behaviors/dom/setup-buffer-actors';
2324
import { setupMediaSource } from '../../behaviors/dom/setup-mediasource';
2425
import { trackCurrentTime } from '../../behaviors/dom/track-current-time';
2526
import { trackLoadTriggers } from '../../behaviors/dom/track-load-triggers';
2627
import { updateMediaSourceDuration } from '../../behaviors/dom/update-mediasource-duration';
27-
import { resolveCdnPriority } from '../../behaviors/resolve-cdn-priority';
2828
import { type ParsePresentation, resolvePresentation } from '../../behaviors/resolve-presentation';
2929
import { resolveAudioTrack } from '../../behaviors/resolve-track';
3030
import { type FailoverMonitorConfig, setupFailoverMonitor } from '../../behaviors/setup-failover-monitor';
@@ -56,7 +56,7 @@ export interface SimpleHlsAudioOnlyEngineState {
5656
userAudioTrackSelection?: Partial<AudioTrack>;
5757
/**
5858
* The CDNs the source is served from, in manifest priority order (mirrors
59-
* HLS content steering's `PATHWAY-PRIORITY`). Owned by `resolveCdnPriority`,
59+
* HLS content steering's `PATHWAY-PRIORITY`). Owned by `deriveCdnPriority`,
6060
* read by `track-switching`'s `preferActiveCdn` scope. Only meaningful for
6161
* redundant-stream sources; a single-CDN source has one entry.
6262
*/
@@ -178,7 +178,7 @@ export function createHlsAudioOnlyEngine(
178178
// not load-bearing here today. It earns its place for forward-consistency
179179
// with the default engine and for future failover / steering, where the
180180
// active CDN changes dynamically (and selection stays reactive either way).
181-
resolveCdnPriority,
181+
deriveCdnPriority,
182182

183183
// CDN failover cooldown: watches `failedCdns` (tripped directly by audio
184184
// track resolution on a failed media-playlist fetch) and removes each CDN

packages/spf/src/playback/engines/hls/engine.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
calculatePresentationDuration,
2828
type PresentationDurationResolver,
2929
} from '../../behaviors/calculate-presentation-duration';
30+
import { deriveCdnPriority } from '../../behaviors/derive-cdn-priority';
3031
import { endOfStream } from '../../behaviors/dom/end-of-stream';
3132
import { loadAudioSegments, loadTextTrackSegments, loadVideoSegments } from '../../behaviors/dom/load-segments';
3233
import { setupAudioBufferActors, setupVideoBufferActors } from '../../behaviors/dom/setup-buffer-actors';
@@ -36,7 +37,6 @@ import { syncTextTracks } from '../../behaviors/dom/sync-text-tracks';
3637
import { trackCurrentTime } from '../../behaviors/dom/track-current-time';
3738
import { trackLoadTriggers } from '../../behaviors/dom/track-load-triggers';
3839
import { updateMediaSourceDuration } from '../../behaviors/dom/update-mediasource-duration';
39-
import { resolveCdnPriority } from '../../behaviors/resolve-cdn-priority';
4040
import { type ParsePresentation, resolvePresentation } from '../../behaviors/resolve-presentation';
4141
import { resolveAudioTrack, resolveTextTrack, resolveVideoTrack } from '../../behaviors/resolve-track';
4242
import { selectTextTrack } from '../../behaviors/select-tracks';
@@ -77,7 +77,7 @@ export interface SimpleHlsEngineState {
7777
/**
7878
* The CDNs the source is served from (track-URL origins), in manifest
7979
* priority order — most-preferred first (mirrors HLS content steering's
80-
* `PATHWAY-PRIORITY`). Owned by `resolveCdnPriority`, read by
80+
* `PATHWAY-PRIORITY`). Owned by `deriveCdnPriority`, read by
8181
* `track-switching`'s `preferActiveCdn` scope, which narrows to the
8282
* highest-priority CDN with surviving tracks so video / audio / text stay on
8383
* one host. Only meaningful for redundant-stream sources; a single-CDN source
@@ -300,7 +300,7 @@ export function createSimpleHlsEngine(
300300
// media-playlist fetch to the wrong CDN before correcting. Symmetric
301301
// redundant streams (the norm) never hit it — the first-listed CDN is
302302
// already the primary we'd pick anyway.
303-
resolveCdnPriority,
303+
deriveCdnPriority,
304304

305305
// CDN failover cooldown: owns the expiry half of failover — watches
306306
// `failedCdns` (tripped directly by track resolution on a failed

0 commit comments

Comments
 (0)