mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
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:
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`).
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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({});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user