diff --git a/.changes/workspace-startup-first-card-ipc.md b/.changes/workspace-startup-first-card-ipc.md new file mode 100644 index 000000000..7df875c2c --- /dev/null +++ b/.changes/workspace-startup-first-card-ipc.md @@ -0,0 +1,6 @@ +--- +type: perf +area: workspace +--- + +On launch the workspace reads the source list once instead of twice before its first source card (journey J1, `renderer.ipcCallsToFirstCard`). diff --git a/docs/architecture/performance-journeys.md b/docs/architecture/performance-journeys.md index a6d59b474..191441e17 100644 --- a/docs/architecture/performance-journeys.md +++ b/docs/architecture/performance-journeys.md @@ -179,6 +179,32 @@ harness, which is what the ratchet needs. The main process start (`Date.now() - process.uptime()`) is recorded per iteration under `evidence.epochs` for cross-checks. +### Startup work before the first card + +`renderer.ipcCallsToFirstCard` counts what the renderer asks of the main +process before the first card. + +- `PlaylistsService.getAllPlaylists()` shares its first SQLite read: at + startup the playlist effect and the XMLTV source reconciliation both read + the inventory, and the second caller joins the first read and receives a + `structuredClone` of its result. Sharing ends when that read settles or a + `PlaylistsService` write starts. It is limited to startup on purpose: + other services write playlists too (the settings reset deletes them + through `DatabaseService`), and while the startup screen is up no such + action can run. `dbGetAppPlaylistMetas` before the first card: 2 → 1. +- `reconcileEpgSources` stays before the first card on purpose: its + completion bumps `EpgSourceSettingsService.revision()`, the fence that + keeps XMLTV lookups from returning data of a removed source. + +Validation (#1716, Principle 3): deferring the download list, update status +and dashboard recent/favorites reads until after the first render took the +counter from 12 to 7 on a Mac, but moved neither `spawnToFirstCardMs` nor +load→card beyond run-to-run drift on a quiet machine, and it grew +`renderer.initialBytes`, so it was dropped. Those calls were never on the +path the first card waits for. That path is a serial chain of round trips +(the migration reads, the inventory read and `reconcileEpgSources`), so a +serial-depth counter is a better guardrail candidate than a raw call count. + ### Summary schema ```json diff --git a/libs/services/src/lib/playlists.service.inventory-read.spec.ts b/libs/services/src/lib/playlists.service.inventory-read.spec.ts new file mode 100644 index 000000000..d0be5779d --- /dev/null +++ b/libs/services/src/lib/playlists.service.inventory-read.spec.ts @@ -0,0 +1,130 @@ +import { firstValueFrom, of } from 'rxjs'; +import { Playlist } from '@iptvnator/shared/interfaces'; +import { PlaylistsService } from './playlists.service'; + +// 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() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => (resolve = done)); + return { promise, resolve }; + } + + async function until(ready: () => boolean, what: string) { + for (let turn = 0; !ready(); turn += 1) { + if (turn > 100) throw new Error(`${what} never started`); + await Promise.resolve(); + } + } + + function setup() { + const reads: ReturnType>[] = []; + const electron = { + dbGetAppState: jest.fn( + async (_key: string): Promise => '1' + ), + dbSetAppState: jest.fn(), + dbRecoverLegacyPlaylists: jest.fn(async () => undefined), + dbGetAppPlaylists: jest.fn(async () => []), + dbGetAppPlaylistMetas: jest.fn(() => { + const read = deferred(); + 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, + playlistWriteQueues: new Map(), + }); + const settle = async (index: number, playlists: Playlist[]) => { + // The read starts after the memoized migration's awaits. + await until(() => !!reads[index], `metadata read ${index}`); + 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('stops sharing once the startup read settled', async () => { + const { electron, service, settle } = setup(); + + const startup = firstValueFrom(service.getAllPlaylists()); + await settle(0, [source('startup')]); + await startup; + // Writes that bypass this service (e.g. the settings reset through + // DatabaseService) are possible from here on, so every caller reads. + const first = firstValueFrom(service.getAllPlaylists()); + const second = firstValueFrom(service.getAllPlaylists()); + await settle(1, [source('one')]); + await settle(2, [source('two')]); + + await expect(first).resolves.toEqual([source('one')]); + await expect(second).resolves.toEqual([source('two')]); + expect(electron.dbGetAppPlaylistMetas).toHaveBeenCalledTimes(3); + }); + + 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()); + await until( + () => electron.dbGetAppPlaylistMetas.mock.calls.length > 0, + 'metadata read 0' + ); + 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); + }); +}); diff --git a/libs/services/src/lib/playlists.service.ts b/libs/services/src/lib/playlists.service.ts index 9a3069fc9..9719348bf 100644 --- a/libs/services/src/lib/playlists.service.ts +++ b/libs/services/src/lib/playlists.service.ts @@ -116,6 +116,12 @@ export class PlaylistsService { private readonly playlistDeleteCleanups = inject(PLAYLIST_DELETE_CLEANUP, { optional: true }) ?? []; private electronMigrationPromise: Promise | null = null; + // Startup reads the inventory twice at once (the playlist effect and the + // XMLTV source reconciliation); both share one worker round trip. Only the + // first read is shared, while the startup screen still hides every action + // that writes playlists (other services write them too, e.g. the settings + // reset); it ends when that read settles or a write here starts (null). + private startupMetas?: Promise | null; private indexedDbMigrationPromise: Promise | null = null; private readonly playlistWriteQueues = new Map>(); @@ -397,6 +403,7 @@ export class PlaylistsService { return playlist; } + this.startupMetas = null; if (operationId === undefined) { await electron.dbUpsertAppPlaylist(playlist); } else { @@ -414,6 +421,7 @@ export class PlaylistsService { return playlists; } + this.startupMetas = null; await electron.dbUpsertAppPlaylists(playlists); playlists.forEach((playlist) => this.healthEvidence?.connections.next({ id: playlist._id, playlist })); return playlists; @@ -492,16 +500,27 @@ 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) - ); - }); + const shared = this.startupMetas; + // A joining caller gets its own copy of the shared result. + if (shared) return from(shared.then((p) => structuredClone(p))); + const read = 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) + ); + }) + ); + if (shared === undefined) { + this.startupMetas = read; + const end = () => (this.startupMetas = null); + read.then(end, end); + } + return from(read); } return this.runOnIndexedDb(() => @@ -555,6 +574,7 @@ export class PlaylistsService { await this.ensureElectronPlaylistMigrations(); const electron = this.electronApi; if (electron) { + this.startupMetas = null; if (options) { const deleted = await this.databaseService.deletePlaylist( @@ -1326,6 +1346,7 @@ export class PlaylistsService { await this.ensureElectronPlaylistMigrations(); const electron = this.electronApi; if (electron) { + this.startupMetas = null; await electron.dbDeleteAllPlaylists(); this.healthEvidence?.connections.next({}); } diff --git a/libs/services/src/test-setup.ts b/libs/services/src/test-setup.ts index 1ca0136b9..97f389d3a 100644 --- a/libs/services/src/test-setup.ts +++ b/libs/services/src/test-setup.ts @@ -7,3 +7,8 @@ setupZonelessTestEnv({ errorOnUnknownElements: true, errorOnUnknownProperties: true, }); + +// jsdom lacks structuredClone; Electron's renderer, the only runtime that +// reads playlists from SQLite, has it. +globalThis.structuredClone ??= (value: T): T => + JSON.parse(JSON.stringify(value)) as T;