fix(settings): retire multi-source alternatives on a lock change and keep reconcile off in-flight stamps

The VOD multi-source host keys its discovery session to the parental
lock version: a lock change drops the discovered sources, retires
discoveries and switches in flight, and rediscovers through the
worker's new lock state. Stale-index entries of a write still stamping
are no longer retried by a concurrent reconcile, which could re-stamp
from a store the write had not committed yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-27 14:58:38 +02:00
1 parent 4ddacab663
commit 4aa1b5a8e6
8 files changed
+121 -4

No files matched your search

+10 -2
View File
@@ -144,7 +144,12 @@ lacks the worker filter (`supportsParentalLockSqliteFilter`:
preload): the worker would never learn the lock state, so it cannot
withhold locked rows itself. The direct worker consumers
(`CatalogTitleMatchService`, `VodSourceDiscoveryService`) ask the worker
nothing while `withholdsEverything` is true, in either case.
nothing while `withholdsEverything` is true, in either case. The VOD
multi-source host keys its discovery session to the lock version: a lock
change drops the discovered sources, retires discoveries and switches in
flight, and rediscovers through the worker's new lock state. Title-match
results cached by the Actor/Discover routes and dashboard services are not
yet retired on relock (tracked in #1723).
The window before the initial read settles is treated the same way
(`ParentalLockLockStore.readable` is false until then): settings can report
the feature as on before the locks are known — and the workspace route's
@@ -409,7 +414,10 @@ by the index alone:
failed edit cannot take effect through that re-stamp. For the whole
re-stamp window — store changed, stamps not yet landed — the playlist
counts as stale, so `readable` is false and a relock inside the window
reloads fail-closed instead of through the old stamps.
reloads fail-closed instead of through the old stamps. Such in-flight
entries are not retried by a reconcile running beside the write (a PIN
prompt or backup calling `ensureReadable()`): it would stamp from a store
the write has not committed yet. Only failed stamps are retryable.
- A write that removes a playlist's LAST lock clears the index first and
drops the key afterwards: the launch-time reconcile finds playlists only
through their key, so an interruption must leave store-with-lock and
@@ -5,6 +5,7 @@ import {
VodSourceResolverService,
} from '@iptvnator/portal/shared/data-access';
import {
ParentalLockService,
SettingsStore,
StreamProbeService,
VodSourcePinService,
@@ -42,6 +43,7 @@ describe('VodMultiSourceHostService — session lifecycle', () => {
const playbackLive = signal(false);
const playbackStartBlocked = signal(false);
const vodAutoFailover = signal(false);
const lockVersion = signal(0);
const startPlayback = jest.fn();
const discovery = { isAvailable: true, discover: jest.fn() };
const resolver = { resolve: jest.fn() };
@@ -77,6 +79,7 @@ describe('VodMultiSourceHostService — session lifecycle', () => {
movie.set(null);
playbackStartBlocked.set(false);
vodAutoFailover.set(false);
lockVersion.set(0);
discovery.isAvailable = true;
discovery.discover.mockResolvedValue({
sources: [],
@@ -96,6 +99,10 @@ describe('VodMultiSourceHostService — session lifecycle', () => {
{ provide: VodSourcePinService, useValue: pins },
{ provide: StreamProbeService, useValue: probes },
{ provide: SettingsStore, useValue: { vodAutoFailover } },
{
provide: ParentalLockService,
useValue: { version: lockVersion },
},
],
});
@@ -131,6 +138,33 @@ describe('VodMultiSourceHostService — session lifecycle', () => {
expect(discovery.discover).toHaveBeenCalledTimes(2);
});
it('drops the discovered sources and rediscovers when the lock version changes', async () => {
discovery.discover.mockResolvedValue({
sources: [ALT_TWO],
matchKind: 'title-year',
});
movie.set(MOVIE_A);
await flushEffects();
expect(service.sources().some((row) => row.id === ALT_TWO.id)).toBe(
true
);
// Lock now: ALT_TWO's category may be locked; the worker answers
// the rediscovery under the new lock state.
discovery.discover.mockResolvedValue({
sources: [],
matchKind: 'title-year',
});
lockVersion.set(1);
await flushEffects();
expect(discovery.discover).toHaveBeenCalledTimes(2);
expect(service.sources().some((row) => row.id === ALT_TWO.id)).toBe(
false
);
await expect(service.play(ALT_TWO.id)).resolves.toBe(false);
});
it('rediscovers the same movie once enrichment describes it better', async () => {
movie.set(MOVIE_A);
await flushEffects();
@@ -5,6 +5,7 @@ import {
VodSourceResolverService,
} from '@iptvnator/portal/shared/data-access';
import {
ParentalLockService,
SettingsStore,
StreamProbeService,
VodSourcePinService,
@@ -124,6 +125,7 @@ export function setupVodMultiSourceHost() {
const playbackLive = signal(false);
const playbackStartBlocked = signal(false);
const vodAutoFailover = signal(false);
const lockVersion = signal(0);
const startPlayback = jest.fn();
const discovery = { isAvailable: true, discover: jest.fn() };
const resolver = { resolve: jest.fn() };
@@ -135,6 +137,7 @@ export function setupVodMultiSourceHost() {
playbackLive,
playbackStartBlocked,
vodAutoFailover,
lockVersion,
startPlayback,
discovery,
resolver,
@@ -150,6 +153,7 @@ export function setupVodMultiSourceHost() {
playbackLive.set(false);
playbackStartBlocked.set(false);
vodAutoFailover.set(false);
lockVersion.set(0);
discovery.isAvailable = true;
discovery.discover.mockResolvedValue({
sources: [],
@@ -169,6 +173,10 @@ export function setupVodMultiSourceHost() {
{ provide: VodSourcePinService, useValue: pins },
{ provide: StreamProbeService, useValue: probes },
{ provide: SettingsStore, useValue: { vodAutoFailover } },
{
provide: ParentalLockService,
useValue: { version: lockVersion },
},
],
});
@@ -5,6 +5,7 @@ import {
VodSourceResolverService,
} from '@iptvnator/portal/shared/data-access';
import {
ParentalLockService,
SettingsStore,
StreamProbeService,
VodSourcePinService,
@@ -42,6 +43,7 @@ describe('VodMultiSourceHostService', () => {
const playbackLive = signal(false);
const playbackStartBlocked = signal(false);
const vodAutoFailover = signal(false);
const lockVersion = signal(0);
const startPlayback = jest.fn();
const discovery = { isAvailable: true, discover: jest.fn() };
const resolver = { resolve: jest.fn() };
@@ -91,6 +93,7 @@ describe('VodMultiSourceHostService', () => {
movie.set(null);
playbackStartBlocked.set(false);
vodAutoFailover.set(false);
lockVersion.set(0);
discovery.isAvailable = true;
discovery.discover.mockResolvedValue({
sources: [],
@@ -110,6 +113,10 @@ describe('VodMultiSourceHostService', () => {
{ provide: VodSourcePinService, useValue: pins },
{ provide: StreamProbeService, useValue: probes },
{ provide: SettingsStore, useValue: { vodAutoFailover } },
{
provide: ParentalLockService,
useValue: { version: lockVersion },
},
],
});
@@ -14,6 +14,7 @@ import {
VodSourceResolverService,
} from '@iptvnator/portal/shared/data-access';
import {
ParentalLockService,
SettingsStore,
StreamProbeService,
VodSourcePinService,
@@ -97,6 +98,7 @@ export class VodMultiSourceHostService {
private readonly probes = inject(StreamProbeService);
private readonly probeCache = inject(VodSourceProbeCacheService);
private readonly settingsStore = inject(SettingsStore);
private readonly parentalLock = inject(ParentalLockService);
/**
* At most this many availability checks in flight at once. Each check is
@@ -116,6 +118,7 @@ export class VodMultiSourceHostService {
/** Bumped by every switch: a slower one must not overwrite a newer. */
private switchToken = 0;
private lastMovieKey: string | null = null;
private lastLockVersion: number | null = null;
private movieIdentity: string | null = null;
/** The row standing for the playlist the route is on. */
private routeSourceId: string | null = null;
@@ -170,6 +173,24 @@ export class VodMultiSourceHostService {
effect(() => {
const movie = bindings.movie();
// A lock change starts a fresh session: alternatives discovered
// under the previous lock state may sit in a now-locked
// category, and a discovery or switch still in flight must not
// publish or play them. Rediscovery reads through the worker's
// new lock state.
const lockVersion = this.parentalLock.version();
if (
this.lastLockVersion !== null &&
this.lastLockVersion !== lockVersion
) {
this.discoveryToken++;
this.sessionToken++;
this.movieIdentity = null;
this.lastMovieKey = null;
this.controller = new VodMultiSourceController();
this._sources.set([]);
}
this.lastLockVersion = lockVersion;
if (!movie) {
// Navigating away empties the identity before the next movie's
// `load()` runs, so bumping here — not only there — closes the
@@ -498,6 +498,28 @@ describe('ParentalLockLockStore', () => {
expect(storage.writeLocks).toHaveBeenCalledTimes(1);
expect(store.readable()).toBe(true);
});
it('does not re-stamp an in-flight write from the uncommitted store', async () => {
await store.load();
await store.ensureReadable();
setCategoryLocks.mockClear();
let finishWrite: (ok: boolean) => void = () => undefined;
storage.writeLocks.mockImplementationOnce(
() => new Promise<boolean>((resolve) => (finishWrite = resolve))
);
const clearing = store.setXtreamLocks('pl-1', 'live', []);
await waitFor(() => storage.writeLocks.mock.calls.length > 0);
// A PIN prompt or backup reaches ensureReadable() beside the write.
await expect(store.ensureReadable()).resolves.toBe(false);
finishWrite(true);
await expect(clearing).resolves.toBe(true);
// Only the write's own clear: memory still held [7] while it was in
// flight, and a reconcile stamping that would have re-locked rows
// the store no longer lists.
expect(setCategoryLocks.mock.calls).toEqual([['pl-1', 'live', []]]);
expect(store.readable()).toBe(true);
});
});
async function waitFor(condition: () => boolean): Promise<void> {
@@ -481,7 +481,7 @@ export class ParentalLockLockStore {
*/
private markStaleWhileStamping(playlistId: string): void {
if (this.runtime.supportsXtreamSqliteDataSource) {
this.staleIndex.mark(playlistId);
this.staleIndex.markInFlight(playlistId);
}
}
@@ -8,15 +8,31 @@ import { computed, signal } from '@angular/core';
*/
export class ParentalLockStaleIndex {
private readonly playlists = new Set<string>();
/**
* Listed while a queued write is stamping them: stale (not `readable`)
* but not retryable — a reconcile running beside the write would
* re-stamp from a store the write has not committed yet.
*/
private readonly inFlight = new Set<string>();
private readonly count = signal(0);
readonly isEmpty = computed(() => this.count() === 0);
/** The retryable entries: not the ones a write is still stamping. */
ids(): string[] {
return [...this.playlists];
return [...this.playlists].filter((id) => !this.inFlight.has(id));
}
/** Stale and retryable (a failed stamp or rollback). */
mark(playlistId: string): void {
this.inFlight.delete(playlistId);
this.playlists.add(playlistId);
this.count.set(this.playlists.size);
}
/** Stale while the write in progress stamps it. */
markInFlight(playlistId: string): void {
this.inFlight.add(playlistId);
this.playlists.add(playlistId);
this.count.set(this.playlists.size);
}
@@ -24,6 +40,7 @@ export class ParentalLockStaleIndex {
unmark(...playlistIds: string[]): void {
for (const playlistId of playlistIds) {
this.playlists.delete(playlistId);
this.inFlight.delete(playlistId);
}
this.count.set(this.playlists.size);
}