perf(services): share the startup inventory read (J1) (#1716)

* 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>

* fix(services): trust a migration receipt written in this session; correct J1 note

- Mark the IndexedDB -> SQLite receipt as confirmed after an empty-store
  receipt write or a committed dbMigrateAppPlaylists, not only when it was
  already present, so M3U favorites skip the per-playlist re-read on the
  first launch after an upgrade too.
- The release note no longer claims a wall-clock speedup the J1 benchmark
  did not show.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(services): keep only the shared startup inventory read (J1)

Drop the startup deferral gate, its kill switch and the deferred loads:
they lowered renderer.ipcCallsToFirstCard but moved neither
spawnToFirstCardMs nor load->card beyond drift, and they grew
renderer.initialBytes. Keep the shared in-flight inventory read, inline
it in PlaylistsService with short property names, and copy the joiner's
result with structuredClone (the Electron renderer has it; the services
test setup polyfills it for jsdom).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(performance): describe the shared inventory read and the J1 validation result

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(services): drop the migration receipt memo for M3U favorites

Skipping the receipt read let the dashboard ask the database for M3U
favorite channels before a just-toggled favorite was written: the write
waits in the per-playlist queue and the cross-context lock, and the
dashboard does not reload when the store's favorites are unchanged.
The extra round trip had been masking that race, and the Windows E2E
run hit it (epg.e2e.ts "dashboard live rails find a programme that only
another playlist's XMLTV carries"). The memo was off the first card's
path, so it bought nothing measurable for J1; restore the per-call read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(services): share only the startup inventory read

A pending read was shared with any concurrent caller, but not every
playlist write goes through PlaylistsService: the settings reset deletes
all playlists through DatabaseService, so a caller could join a read
taken before that deletion (Codex review). Share only the first read,
which the startup pair (playlist effect and XMLTV reconciliation) needs
while the startup screen still hides every writing action; sharing ends
when it settles or a PlaylistsService write starts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-09-29 17:41:42 +02:00
1 parent 0f768b6e20
commit d60c80746b
5 files changed
+198 -10

No files matched your search

@@ -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`).
+26
View File
@@ -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
@@ -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<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((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<typeof deferred<Playlist[]>>[] = [];
const electron = {
dbGetAppState: jest.fn(
async (_key: string): Promise<string | null> => '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,
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);
});
});
+31 -10
View File
@@ -116,6 +116,12 @@ 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. 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<Playlist[]> | null;
private indexedDbMigrationPromise: Promise<void> | null = null;
private readonly playlistWriteQueues = new Map<string, Promise<unknown>>();
@@ -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({});
}
+5
View File
@@ -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 ??= <T>(value: T): T =>
JSON.parse(JSON.stringify(value)) as T;