perf(workspace): share the startup inventory read and defer non-first-card IPC (J1)

Journey J1 / renderer.ipcCallsToFirstCard: 12 -> 7.

- PlaylistsService.getAllPlaylists() shares one in-flight SQLite read
  between concurrent callers (the playlist effect and the XMLTV source
  reconciliation at startup). Settled reads are never reused and every
  SQLite write detaches the pending read.
- getM3uFavoriteChannels() stops re-reading the write-once IndexedDB ->
  SQLite migration receipt once it has been seen.
- StartupDeferralService holds the download list, app update status and
  the dashboard's recent items and favorites until one task after the
  render that reveals the routed content (5 s safety timeout).
  IPTVNATOR_DISABLE_STARTUP_DEFERRAL=1 is the kill switch.
- reconcileEpgSources and the two distinct migration-flag reads stay on
  the critical path: the first is the #1548 revision fence, the second
  are different keys, not duplicates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-29 08:59:35 +02:00
1 parent 76dd8c099e
commit e5ee064407
24 files changed
+648 -23

No files matched your search

+1
View File
@@ -16,6 +16,7 @@ export * from './lib/playlists.service';
export * from './lib/portal-status.service';
export * from './lib/runtime-capabilities.service';
export * from './lib/settings-store.service';
export * from './lib/startup-deferral.service';
export * from './lib/sort.service';
export * from './lib/tmdb';
export * from './lib/xtream-pending-restore.service';
@@ -15,6 +15,10 @@ import type {
import { DownloadListLoadState } from './download-list-load-state';
import { DownloadItem, DownloadsService } from './downloads.service';
import { RuntimeCapabilitiesService } from './runtime-capabilities.service';
import {
STARTUP_WORK_DEFERRAL,
StartupDeferralService,
} from './startup-deferral.service';
type TestDownloadsService = {
downloads: WritableSignal<DownloadItem[]>;
@@ -150,6 +154,7 @@ describe('DownloadsService', () => {
const injector = createEnvironmentInjector(
[
DownloadsService,
StartupDeferralService,
{
provide: RuntimeCapabilitiesService,
useValue: { supportsDownloads: false },
@@ -170,6 +175,44 @@ describe('DownloadsService', () => {
}
});
it('subscribes at once but loads the list after the first screen rendered', async () => {
jest.useFakeTimers();
const onDownloadsUpdate = jest.fn(() => () => undefined);
const electron = {
onDownloadsUpdate,
downloadsGetList: jest.fn(async () => []),
downloadsGetDefaultFolder: jest.fn(async () => '/downloads'),
};
testWindow.electron = electron as unknown as DownloadsElectronStub;
const injector = createEnvironmentInjector(
[
DownloadsService,
StartupDeferralService,
{ provide: STARTUP_WORK_DEFERRAL, useValue: true },
{
provide: RuntimeCapabilitiesService,
useValue: { supportsDownloads: true },
},
],
Injector.NULL as unknown as EnvironmentInjector
);
try {
runInInjectionContext(injector, () => new DownloadsService());
await Promise.resolve();
expect(onDownloadsUpdate).toHaveBeenCalledTimes(1);
expect(electron.downloadsGetList).not.toHaveBeenCalled();
injector.get(StartupDeferralService).markFirstContentRendered();
await jest.advanceTimersByTimeAsync(0);
expect(electron.downloadsGetList).toHaveBeenCalledTimes(1);
} finally {
injector.destroy();
jest.useRealTimers();
}
});
it('loads the global download list without a playlist filter and tracks request state', async () => {
const item = createDownload(1);
const pending = createDeferred<DownloadItem[]>();
@@ -7,6 +7,7 @@ import { updateDownloadMetadata } from './downloads-metadata-update';
import type { DownloadItem, DownloadStartInput } from './downloads.models';
import { formatDownloadBytes } from './downloads.utils';
import { RuntimeCapabilitiesService } from './runtime-capabilities.service';
import { StartupDeferralService } from './startup-deferral.service';
export type {
DownloadItem,
@@ -17,6 +18,7 @@ export type {
@Injectable({ providedIn: 'root' })
export class DownloadsService implements OnDestroy {
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly startupDeferral = inject(StartupDeferralService);
private unsubscribe?: () => void;
private readonly downloadListLoadState = new DownloadListLoadState();
@@ -79,6 +81,10 @@ export class DownloadsService implements OnDestroy {
this.loadDownloads();
});
// The header badge is the only startup consumer; views that need
// the list load it themselves. Keep it off the first screen (J1).
await this.startupDeferral.whenFirstContentRendered();
// Load initial download list
await this.loadDownloads();
@@ -1,5 +1,6 @@
import { firstValueFrom, of } from 'rxjs';
import { PlaylistsService } from './playlists.service';
import { SharedInFlightRead } from './shared-in-flight-read';
describe('Electron legacy playlist migration', () => {
const original = window.electron;
@@ -38,6 +39,7 @@ describe('Electron legacy playlist migration', () => {
dbService,
runtime: { supportsSqlite: true },
electronMigrationPromise: null,
sqliteInventoryRead: new SharedInFlightRead(),
});
return { playlists, dbService, electron, service };
}
@@ -0,0 +1,120 @@
import { firstValueFrom, of } from 'rxjs';
import { Playlist } from '@iptvnator/shared/interfaces';
import { PlaylistsService } from './playlists.service';
import { SharedInFlightRead } from './shared-in-flight-read';
// Performance journey J1: the playlist effect and the XMLTV source
// reconciliation both read the inventory at startup. They must share one
// worker round trip, without ever handing a caller a pre-write inventory.
describe('PlaylistsService inventory reads', () => {
const original = window.electron;
afterEach(() => {
window.electron = original;
jest.restoreAllMocks();
});
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => (resolve = done));
return { promise, resolve };
}
function setup() {
const reads: ReturnType<typeof deferred<Playlist[]>>[] = [];
const electron = {
dbGetAppState: jest.fn(async () => '1'),
dbSetAppState: jest.fn(),
dbRecoverLegacyPlaylists: jest.fn(async () => undefined),
dbGetAppPlaylists: jest.fn(async () => []),
dbGetAppPlaylistMetas: jest.fn(() => {
const read = deferred<Playlist[]>();
reads.push(read);
return read.promise;
}),
dbGetAppPlaylistFavoriteChannels: jest.fn(async () => []),
dbUpsertAppPlaylist: jest.fn(async () => undefined),
};
window.electron = electron as unknown as typeof window.electron;
const service = Object.create(
PlaylistsService.prototype
) as PlaylistsService;
Object.assign(service, {
dbService: { getAll: jest.fn(() => of([])) },
runtime: { supportsSqlite: true },
electronMigrationPromise: null,
sqliteInventoryRead: new SharedInFlightRead(),
sqliteMigrationConfirmed: false,
playlistWriteQueues: new Map(),
});
const settle = async (index: number, playlists: Playlist[]) => {
// The read starts after the memoized migration's awaits.
while (!reads[index]) await Promise.resolve();
reads[index].resolve(playlists);
};
return { electron, service, settle };
}
const source = (id: string) =>
({ _id: id, title: id, favorites: ['a'] }) as unknown as Playlist;
it('shares one metadata read between concurrent callers', async () => {
const { electron, service, settle } = setup();
const effectRead = firstValueFrom(service.getAllPlaylists());
const reconcileRead = firstValueFrom(service.getAllPlaylists());
await settle(0, [source('m3u')]);
const [first, second] = await Promise.all([effectRead, reconcileRead]);
expect(electron.dbGetAppPlaylistMetas).toHaveBeenCalledTimes(1);
expect(second).toEqual(first);
// The joiner holds its own copy: no caller mutates another's result.
expect(second).not.toBe(first);
expect(second[0].favorites).not.toBe(first[0].favorites);
});
it('never reuses a settled read', async () => {
const { electron, service, settle } = setup();
const first = firstValueFrom(service.getAllPlaylists());
await settle(0, [source('before')]);
await first;
const second = firstValueFrom(service.getAllPlaylists());
await settle(1, [source('after')]);
await expect(second).resolves.toEqual([source('after')]);
expect(electron.dbGetAppPlaylistMetas).toHaveBeenCalledTimes(2);
});
it('starts a fresh read for callers that arrive after a write', async () => {
const { electron, service, settle } = setup();
const added = source('added');
const staleRead = firstValueFrom(service.getAllPlaylists());
while (electron.dbGetAppPlaylistMetas.mock.calls.length === 0)
await Promise.resolve();
await firstValueFrom(service.addPlaylist(added));
const freshRead = firstValueFrom(service.getAllPlaylists());
await settle(0, []);
await settle(1, [added]);
await expect(staleRead).resolves.toEqual([]);
await expect(freshRead).resolves.toEqual([added]);
expect(electron.dbGetAppPlaylistMetas).toHaveBeenCalledTimes(2);
});
it('reads the SQLite migration receipt once for M3U favorites', async () => {
const { electron, service, settle } = setup();
const inventory = firstValueFrom(service.getAllPlaylists());
await settle(0, []);
await inventory;
const receiptReads = electron.dbGetAppState.mock.calls.length;
await firstValueFrom(service.getM3uFavoriteChannels('one'));
await firstValueFrom(service.getM3uFavoriteChannels('two'));
expect(electron.dbGetAppState).toHaveBeenCalledTimes(receiptReads);
expect(
electron.dbGetAppPlaylistFavoriteChannels
).toHaveBeenCalledTimes(2);
});
});
@@ -1,6 +1,7 @@
import { EMPTY, firstValueFrom, from, of } from 'rxjs';
import { DbStores, Playlist, PlaylistMeta } from '@iptvnator/shared/interfaces';
import { PlaylistsService, resolvePlaylistParser } from './playlists.service';
import { SharedInFlightRead } from './shared-in-flight-read';
const SQLITE_PLAYLIST_MIGRATION_FLAG = 'm3u-playlists-indexeddb-to-sqlite-v1';
const STALKER_PLAYLIST_METADATA_MIGRATION_FLAG =
@@ -60,6 +61,8 @@ describe('PlaylistsService', () => {
},
electronMigrationPromise: null,
indexedDbMigrationPromise: null,
sqliteInventoryRead: new SharedInFlightRead(),
sqliteMigrationConfirmed: false,
playlistWriteQueues: new Map(),
playlistDeleteCleanups: [],
});
+37 -16
View File
@@ -45,6 +45,7 @@ import {
runWithPlaylistAuthorityReset,
} from './playlist-cross-context-lock';
import { RuntimeCapabilitiesService } from './runtime-capabilities.service';
import { SharedInFlightRead } from './shared-in-flight-read';
const SQLITE_PLAYLIST_MIGRATION_FLAG = 'm3u-playlists-indexeddb-to-sqlite-v1';
const STALKER_PLAYLIST_METADATA_MIGRATION_FLAG =
@@ -116,6 +117,11 @@ export class PlaylistsService {
private readonly playlistDeleteCleanups =
inject(PLAYLIST_DELETE_CLEANUP, { optional: true }) ?? [];
private electronMigrationPromise: Promise<void> | null = null;
// Startup reads the inventory twice at once (the playlist effect and the
// XMLTV source reconciliation); both share one worker round trip.
private readonly sqliteInventoryRead = new SharedInFlightRead<Playlist[]>();
// The IndexedDB → SQLite receipt is written once and never cleared.
private sqliteMigrationConfirmed = false;
private indexedDbMigrationPromise: Promise<void> | null = null;
private readonly playlistWriteQueues = new Map<string, Promise<unknown>>();
@@ -195,8 +201,10 @@ export class PlaylistsService {
if (
(await electron.dbGetAppState(SQLITE_PLAYLIST_MIGRATION_FLAG)) ===
'1'
)
) {
this.sqliteMigrationConfirmed = true;
return;
}
const playlists = await firstValueFrom(
this.dbService.getAll<Playlist>(DbStores.Playlists)
);
@@ -397,6 +405,7 @@ export class PlaylistsService {
return playlist;
}
this.sqliteInventoryRead.detach();
if (operationId === undefined) {
await electron.dbUpsertAppPlaylist(playlist);
} else {
@@ -414,6 +423,7 @@ export class PlaylistsService {
return playlists;
}
this.sqliteInventoryRead.detach();
await electron.dbUpsertAppPlaylists(playlists);
playlists.forEach((playlist) => this.healthEvidence?.connections.next({ id: playlist._id, playlist }));
return playlists;
@@ -492,16 +502,22 @@ export class PlaylistsService {
getAllPlaylists() {
if (this.isElectronStorageAvailable) {
return this.runOnSqlite(async () => {
const electron = this.electronApi;
const playlists = electron
? await (electron.dbGetAppPlaylistMetas?.() ??
electron.dbGetAppPlaylists())
: [];
return (playlists as Playlist[]).map((playlist) =>
this.toPlaylistMeta(playlist)
);
});
return from(
this.sqliteInventoryRead.run(() =>
firstValueFrom(
this.runOnSqlite(async () => {
const electron = this.electronApi;
const playlists = electron
? await (electron.dbGetAppPlaylistMetas?.() ??
electron.dbGetAppPlaylists())
: [];
return (playlists as Playlist[]).map((playlist) =>
this.toPlaylistMeta(playlist)
);
})
)
)
);
}
return this.runOnIndexedDb(() =>
@@ -555,6 +571,7 @@ export class PlaylistsService {
await this.ensureElectronPlaylistMigrations();
const electron = this.electronApi;
if (electron) {
this.sqliteInventoryRead.detach();
if (options) {
const deleted =
await this.databaseService.deletePlaylist(
@@ -1072,11 +1089,14 @@ export class PlaylistsService {
return from(
(async () => {
const alreadyMigrated = await electron.dbGetAppState(
SQLITE_PLAYLIST_MIGRATION_FLAG
);
if (alreadyMigrated !== '1') {
return null;
if (!this.sqliteMigrationConfirmed) {
const alreadyMigrated = await electron.dbGetAppState(
SQLITE_PLAYLIST_MIGRATION_FLAG
);
if (alreadyMigrated !== '1') {
return null;
}
this.sqliteMigrationConfirmed = true;
}
return getFavoriteChannels(playlistId);
@@ -1326,6 +1346,7 @@ export class PlaylistsService {
await this.ensureElectronPlaylistMigrations();
const electron = this.electronApi;
if (electron) {
this.sqliteInventoryRead.detach();
await electron.dbDeleteAllPlaylists();
this.healthEvidence?.connections.next({});
}
@@ -0,0 +1,52 @@
/**
* Lets concurrent callers share one pending read. A settled read is never
* reused, and `detach()` (called when a write is issued) makes the next
* caller start a fresh read, so a caller that follows a write never receives
* a result that was read before it. The first caller receives the read's own
* result; callers that join it receive a copy, so no caller can mutate what
* another one holds.
*/
export class SharedInFlightRead<T> {
private pending: Promise<T> | null = null;
constructor(private readonly copy: (value: T) => T = copyPlainData) {}
run(read: () => Promise<T>): Promise<T> {
if (this.pending) {
return this.pending.then(this.copy);
}
const pending = read();
this.pending = pending;
const settle = () => {
if (this.pending === pending) this.pending = null;
};
pending.then(settle, settle);
return pending;
}
detach(): void {
this.pending = null;
}
}
/**
* Deep-copies arrays and plain objects (the shape of worker results); any
* other value, such as a `Date`, is shared by reference.
*/
export function copyPlainData<T>(value: T): T {
if (Array.isArray(value)) {
return value.map((item) => copyPlainData(item)) as T;
}
if (
value !== null &&
typeof value === 'object' &&
Object.getPrototypeOf(value) === Object.prototype
) {
const copy: Record<string, unknown> = {};
for (const [key, item] of Object.entries(value)) {
copy[key] = copyPlainData(item);
}
return copy as T;
}
return value;
}
@@ -0,0 +1,80 @@
import { TestBed } from '@angular/core/testing';
import {
STARTUP_DEFERRAL_SAFETY_TIMEOUT_MS,
STARTUP_WORK_DEFERRAL,
StartupDeferralService,
} from './startup-deferral.service';
describe('StartupDeferralService', () => {
const original = window.electron;
afterEach(() => {
window.electron = original;
jest.useRealTimers();
});
function create(armed: boolean) {
TestBed.configureTestingModule({
providers: armed
? [{ provide: STARTUP_WORK_DEFERRAL, useValue: true }]
: [],
});
return TestBed.inject(StartupDeferralService);
}
function track(service: StartupDeferralService) {
const state = { open: false };
void service.whenFirstContentRendered().then(() => (state.open = true));
return state;
}
it('is open when the application did not arm it', async () => {
const state = track(create(false));
await Promise.resolve();
expect(state.open).toBe(true);
});
it('opens one task after the first content rendered', async () => {
jest.useFakeTimers();
const service = create(true);
const state = track(service);
await Promise.resolve();
expect(state.open).toBe(false);
service.markFirstContentRendered();
await Promise.resolve();
// Never in the task that rendered the first card.
expect(state.open).toBe(false);
jest.advanceTimersByTime(0);
await Promise.resolve();
expect(state.open).toBe(true);
});
it('opens after the safety timeout when nothing renders', async () => {
jest.useFakeTimers();
const state = track(create(true));
jest.advanceTimersByTime(STARTUP_DEFERRAL_SAFETY_TIMEOUT_MS - 1);
await Promise.resolve();
expect(state.open).toBe(false);
jest.advanceTimersByTime(1);
await Promise.resolve();
expect(state.open).toBe(true);
});
it('honours the IPTVNATOR_DISABLE_STARTUP_DEFERRAL kill switch', async () => {
window.electron = {
startupDeferralDisabled: true,
} as unknown as typeof window.electron;
const state = track(create(true));
await Promise.resolve();
expect(state.open).toBe(true);
});
});
@@ -0,0 +1,68 @@
import { inject, Injectable, InjectionToken } from '@angular/core';
/**
* Arms the startup gate. Only the application shell provides `true`; any
* other injector (unit tests, embedded hosts) sees an open gate, so deferred
* work runs immediately there, exactly as before the gate existed.
*/
export const STARTUP_WORK_DEFERRAL = new InjectionToken<boolean>(
'STARTUP_WORK_DEFERRAL'
);
/**
* Opens the gate even if the first content never renders (a failed source
* read, a route error), so deferred background work is late, never lost.
*/
export const STARTUP_DEFERRAL_SAFETY_TIMEOUT_MS = 5000;
/**
* Holds startup work that the first rendered screen does not need until
* that screen has rendered (performance journey J1). The gate opens one task
* after the render in which the startup status completed, so work released
* by it can never be issued in the same task as the first card.
*/
@Injectable({ providedIn: 'root' })
export class StartupDeferralService {
private readonly enabled =
inject(STARTUP_WORK_DEFERRAL, { optional: true }) === true &&
!isStartupDeferralDisabled();
private open = false;
private openGate!: () => void;
private readonly gate = new Promise<void>((resolve) => {
this.openGate = resolve;
});
constructor() {
if (!this.enabled) {
this.release();
return;
}
setTimeout(() => this.release(), STARTUP_DEFERRAL_SAFETY_TIMEOUT_MS);
}
/** Resolves once the first routed content has rendered. */
whenFirstContentRendered(): Promise<void> {
return this.gate;
}
/** Called after the render in which the startup status completed. */
markFirstContentRendered(): void {
if (!this.open) {
setTimeout(() => this.release(), 0);
}
}
private release(): void {
if (this.open) return;
this.open = true;
this.openGate();
}
}
/** `IPTVNATOR_DISABLE_STARTUP_DEFERRAL=1`, read by the Electron preload. */
function isStartupDeferralDisabled(): boolean {
return (
typeof window !== 'undefined' &&
window.electron?.startupDeferralDisabled === true
);
}