From 28ed38906d07089cea122d2204b73fb63fa9e4d2 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 26 Jul 2026 10:00:24 +0200 Subject: [PATCH 1/9] test(perf): add production renderer benchmark build --- apps/electron-backend-e2e/project.json | 9 +- .../performance-build-config.spec.ts | 173 ++++++++++++++++++ apps/electron-backend/project.json | 26 +++ apps/remote-control-web/project.json | 11 ++ apps/web/project.json | 27 +++ 5 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts diff --git a/apps/electron-backend-e2e/project.json b/apps/electron-backend-e2e/project.json index 1918e34fa..51ed910a8 100644 --- a/apps/electron-backend-e2e/project.json +++ b/apps/electron-backend-e2e/project.json @@ -12,6 +12,13 @@ "e2e": { "dependsOn": ["electron-backend:build-e2e"] }, + "test-performance-harness": { + "executor": "nx:run-commands", + "options": { + "cwd": "apps/electron-backend-e2e", + "command": "pnpm exec tsx --test src/performance/*.spec.ts" + } + }, "benchmark-m3u-refresh-cancellation": { "dependsOn": ["electron-backend:build-e2e"], "executor": "nx:run-commands", @@ -19,7 +26,7 @@ "parallelism": false, "options": { "cwd": "apps/electron-backend-e2e", - "command": "pnpm exec playwright test --config=playwright.performance.config.ts" + "command": "pnpm exec playwright test --config=playwright.performance.config.ts src/m3u-refresh-cancellation.performance.ts" } }, "packaged-frame-copy-smoke": { diff --git a/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts b/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts new file mode 100644 index 000000000..c333d54ab --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts @@ -0,0 +1,173 @@ +/* eslint-disable playwright/expect-expect -- These are Node assertion-based configuration contract tests. */ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; +import { join } from 'node:path'; + +interface TargetConfiguration { + configurations?: Record>; + dependsOn?: unknown; + executor?: unknown; + options?: Record; +} + +interface ProjectConfiguration { + targets: Record; +} + +const workspaceRoot = fileURLToPath(new URL('../../../../', import.meta.url)); + +function readProject(relativePath: string): ProjectConfiguration { + return JSON.parse( + readFileSync(join(workspaceRoot, relativePath), 'utf8') + ) as ProjectConfiguration; +} + +const e2eProject = readProject('apps/electron-backend-e2e/project.json'); +const electronProject = readProject('apps/electron-backend/project.json'); +const remoteControlProject = readProject( + 'apps/remote-control-web/project.json' +); +const webProject = readProject('apps/web/project.json'); + +test('the performance harness target runs only Node performance specs', () => { + const target = e2eProject.targets['test-performance-harness']; + + assert.ok( + target, + 'electron-backend-e2e must define test-performance-harness' + ); + assert.equal(target.executor, 'nx:run-commands'); + assert.equal(target.options?.['cwd'], 'apps/electron-backend-e2e'); + assert.equal( + target.options?.['command'], + 'pnpm exec tsx --test src/performance/*.spec.ts' + ); +}); + +test('the web performance build keeps production renderer behavior with profiling source maps', () => { + const build = webProject.targets['build']; + const production = build.configurations?.['production']; + const performance = build.configurations?.['electron-performance']; + + assert.ok(production, 'web:build must define production'); + assert.ok(performance, 'web:build must define electron-performance'); + assert.equal(performance['baseHref'], './'); + assert.equal(performance['serviceWorker'], false); + assert.deepEqual(performance['optimization'], production['optimization']); + assert.equal(performance['outputHashing'], production['outputHashing']); + assert.deepEqual( + performance['fileReplacements'], + production['fileReplacements'] + ); + assert.equal(performance['sourceMap'], true); +}); + +test('the remote-control performance build keeps optimized hashed output with profiling source maps', () => { + const build = remoteControlProject.targets['build']; + const production = build.configurations?.['production']; + const performance = build.configurations?.['electron-performance']; + + assert.ok(production, 'remote-control-web:build must define production'); + assert.ok( + performance, + 'remote-control-web:build must define electron-performance' + ); + assert.equal(performance['optimization'], true); + assert.equal(performance['outputHashing'], production['outputHashing']); + assert.equal(performance['sourceMap'], true); +}); + +test('the Electron performance build keeps production main-process behavior with profiling source maps', () => { + const build = electronProject.targets['build']; + const production = build.configurations?.['production']; + const performance = build.configurations?.['electron-performance']; + + assert.ok(production, 'electron-backend:build must define production'); + assert.ok( + performance, + 'electron-backend:build must define electron-performance' + ); + assert.equal(performance['optimization'], production['optimization']); + assert.equal(performance['inspect'], production['inspect']); + assert.deepEqual( + performance['fileReplacements'], + production['fileReplacements'] + ); + assert.equal(performance['sourceMap'], true); +}); + +test('the regular Electron build keeps its existing renderer dependency contract', () => { + const build = electronProject.targets['build']; + const dependencies = build.dependsOn as Array< + Record | string + >; + const rendererBuild = dependencies.find( + (dependency): dependency is Record => + typeof dependency === 'object' && + dependency !== null && + dependency['target'] === 'build' + ); + + assert.deepEqual(rendererBuild, { + projects: ['web', 'remote-control-web'], + target: 'build', + }); +}); + +test('the web performance wrapper selects the profiling configuration', () => { + const target = webProject.targets['build-performance']; + + assert.ok(target, 'web must define build-performance'); + assert.equal(target.executor, 'nx:run-commands'); + assert.equal( + target.options?.['command'], + 'pnpm nx run web:build:electron-performance' + ); +}); + +test('the remote-control performance wrapper selects the profiling configuration', () => { + const target = remoteControlProject.targets['build-performance']; + + assert.ok(target, 'remote-control-web must define build-performance'); + assert.equal(target.executor, 'nx:run-commands'); + assert.equal( + target.options?.['command'], + 'pnpm nx run remote-control-web:build:electron-performance' + ); +}); + +test('the Electron performance wrapper owns build dependencies before its profiled build', () => { + const target = electronProject.targets['build-performance']; + + assert.ok(target, 'electron-backend must define build-performance'); + assert.equal(target.executor, 'nx:run-commands'); + assert.deepEqual(target.dependsOn, [ + 'electron-backend:build-worker', + 'electron-backend:build-embedded-mpv', + { + projects: ['web', 'remote-control-web'], + target: 'build-performance', + }, + ]); + assert.equal( + target.options?.['command'], + 'pnpm nx run electron-backend:build:electron-performance --excludeTaskDependencies' + ); +}); + +test('the cancellation benchmark keeps its existing E2E build dependency', () => { + const target = e2eProject.targets['benchmark-m3u-refresh-cancellation']; + + assert.deepEqual(target.dependsOn, ['electron-backend:build-e2e']); +}); + +test('the cancellation benchmark command is pinned to its Playwright test file', () => { + const target = e2eProject.targets['benchmark-m3u-refresh-cancellation']; + + assert.equal( + target.options?.['command'], + 'pnpm exec playwright test --config=playwright.performance.config.ts src/m3u-refresh-cancellation.performance.ts' + ); +}); diff --git a/apps/electron-backend/project.json b/apps/electron-backend/project.json index 8e6b9febb..37b1e7e07 100644 --- a/apps/electron-backend/project.json +++ b/apps/electron-backend/project.json @@ -81,9 +81,35 @@ "with": "apps/electron-backend/src/environments/environment.prod.ts" } ] + }, + "electron-performance": { + "optimization": true, + "extractLicenses": true, + "inspect": false, + "sourceMap": true, + "fileReplacements": [ + { + "replace": "apps/electron-backend/src/environments/environment.ts", + "with": "apps/electron-backend/src/environments/environment.prod.ts" + } + ] } } }, + "build-performance": { + "dependsOn": [ + "electron-backend:build-worker", + "electron-backend:build-embedded-mpv", + { + "projects": ["web", "remote-control-web"], + "target": "build-performance" + } + ], + "executor": "nx:run-commands", + "options": { + "command": "pnpm nx run electron-backend:build:electron-performance --excludeTaskDependencies" + } + }, "build-e2e": { "dependsOn": [ "electron-backend:build-worker", diff --git a/apps/remote-control-web/project.json b/apps/remote-control-web/project.json index 5819a8bf5..8c65582c8 100644 --- a/apps/remote-control-web/project.json +++ b/apps/remote-control-web/project.json @@ -49,6 +49,11 @@ ], "outputHashing": "all" }, + "electron-performance": { + "optimization": true, + "outputHashing": "all", + "sourceMap": true + }, "development": { "optimization": false, "extractLicenses": false, @@ -63,6 +68,12 @@ "command": "pnpm nx run remote-control-web:build:development" } }, + "build-performance": { + "executor": "nx:run-commands", + "options": { + "command": "pnpm nx run remote-control-web:build:electron-performance" + } + }, "serve": { "continuous": true, "executor": "@angular/build:dev-server", diff --git a/apps/web/project.json b/apps/web/project.json index f7b179709..3fb7392b3 100644 --- a/apps/web/project.json +++ b/apps/web/project.json @@ -122,6 +122,27 @@ "extractLicenses": false, "sourceMap": true }, + "electron-performance": { + "baseHref": "./", + "serviceWorker": false, + "optimization": { + "scripts": true, + "styles": { + "minify": true, + "inlineCritical": false, + "removeSpecialComments": true + }, + "fonts": true + }, + "outputHashing": "all", + "sourceMap": true, + "fileReplacements": [ + { + "replace": "apps/web/src/environments/environment.ts", + "with": "apps/web/src/environments/environment.prod.ts" + } + ] + }, "electron-e2e": { "baseHref": "./", "serviceWorker": false, @@ -138,6 +159,12 @@ "command": "pnpm nx run web:build:electron-e2e" } }, + "build-performance": { + "executor": "nx:run-commands", + "options": { + "command": "pnpm nx run web:build:electron-performance" + } + }, "serve": { "continuous": true, "executor": "@angular/build:dev-server", From 2cc7d0acb11f4f444fc4e1956f5f6ec5d315915d Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 26 Jul 2026 10:24:12 +0200 Subject: [PATCH 2/9] fix(perf): declare renderer cache output path --- .../performance-build-config.spec.ts | 53 +++++++++++++++++++ apps/web/project.json | 2 +- 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts b/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts index c333d54ab..33f81351b 100644 --- a/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts +++ b/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts @@ -1,5 +1,6 @@ /* eslint-disable playwright/expect-expect -- These are Node assertion-based configuration contract tests. */ import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import test from 'node:test'; @@ -16,6 +17,20 @@ interface ProjectConfiguration { targets: Record; } +interface NxGraphTask { + outputs?: string[]; + target: { + project: string; + target: string; + }; +} + +interface NxGraph { + tasks: { + tasks: Record; + }; +} + const workspaceRoot = fileURLToPath(new URL('../../../../', import.meta.url)); function readProject(relativePath: string): ProjectConfiguration { @@ -24,6 +39,37 @@ function readProject(relativePath: string): ProjectConfiguration { ) as ProjectConfiguration; } +function readResolvedWebBuildTask(): NxGraphTask | undefined { + const environment = { + ...process.env, + FORCE_COLOR: '0', + NX_DAEMON: 'false', + }; + delete environment['NO_COLOR']; + + const graph = JSON.parse( + execFileSync( + process.execPath, + [ + join(workspaceRoot, 'node_modules/nx/dist/bin/nx.js'), + 'run', + 'web:build', + '--graph=stdout', + ], + { + cwd: workspaceRoot, + encoding: 'utf8', + env: environment, + } + ) + ) as NxGraph; + + return Object.values(graph.tasks.tasks).find( + (task) => + task.target.project === 'web' && task.target.target === 'build' + ); +} + const e2eProject = readProject('apps/electron-backend-e2e/project.json'); const electronProject = readProject('apps/electron-backend/project.json'); const remoteControlProject = readProject( @@ -64,6 +110,13 @@ test('the web performance build keeps production renderer behavior with profilin assert.equal(performance['sourceMap'], true); }); +test('the resolved web build cache output is the renderer directory', () => { + const task = readResolvedWebBuildTask(); + + assert.ok(task, 'the Nx graph must include web:build'); + assert.deepEqual(task.outputs, ['dist/apps/web']); +}); + test('the remote-control performance build keeps optimized hashed output with profiling source maps', () => { const build = remoteControlProject.targets['build']; const production = build.configurations?.['production']; diff --git a/apps/web/project.json b/apps/web/project.json index 3fb7392b3..302ea2e37 100644 --- a/apps/web/project.json +++ b/apps/web/project.json @@ -13,7 +13,7 @@ "targets": { "build": { "executor": "@angular/build:application", - "outputs": ["{options.outputPath}"], + "outputs": ["{workspaceRoot}/dist/apps/web"], "options": { "outputPath": { "base": "dist/apps/web", From e3ce60a35ee9b861128dce33b8e56586af020453 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 26 Jul 2026 11:10:01 +0200 Subject: [PATCH 3/9] chore(electron): add preload performance markers --- AGENTS.md | 1 + CLAUDE.md | 1 + .../main.preload.performance.contract.spec.ts | 318 ++++++++++++++++ .../main.preload.performance.gates.spec.ts | 149 ++++++++ .../main.preload.performance.test-helpers.ts | 162 +++++++++ .../src/app/api/main.preload.ts | 145 +++++--- .../api/preload-performance-capture.spec.ts | 50 +++ .../app/api/preload-performance-capture.ts | 191 ++++++++++ ...reload-performance-correlation.complete.ts | 235 ++++++++++++ ...performance-correlation.completion.spec.ts | 314 ++++++++++++++++ .../preload-performance-correlation.model.ts | 178 +++++++++ ...d-performance-correlation.sequence.spec.ts | 338 ++++++++++++++++++ .../preload-performance-correlation.start.ts | 251 +++++++++++++ ...ad-performance-correlation.test-helpers.ts | 29 ++ .../api/preload-performance-correlation.ts | 60 ++++ ...performance-correlation.validation.spec.ts | 117 ++++++ .../src/app/services/debug-trace.ts | 4 + libs/shared/interfaces/src/index.ts | 1 + .../preload-performance-marker.interface.ts | 52 +++ 19 files changed, 2547 insertions(+), 49 deletions(-) create mode 100644 apps/electron-backend/src/app/api/main.preload.performance.contract.spec.ts create mode 100644 apps/electron-backend/src/app/api/main.preload.performance.gates.spec.ts create mode 100644 apps/electron-backend/src/app/api/main.preload.performance.test-helpers.ts create mode 100644 apps/electron-backend/src/app/api/preload-performance-capture.spec.ts create mode 100644 apps/electron-backend/src/app/api/preload-performance-capture.ts create mode 100644 apps/electron-backend/src/app/api/preload-performance-correlation.complete.ts create mode 100644 apps/electron-backend/src/app/api/preload-performance-correlation.completion.spec.ts create mode 100644 apps/electron-backend/src/app/api/preload-performance-correlation.model.ts create mode 100644 apps/electron-backend/src/app/api/preload-performance-correlation.sequence.spec.ts create mode 100644 apps/electron-backend/src/app/api/preload-performance-correlation.start.ts create mode 100644 apps/electron-backend/src/app/api/preload-performance-correlation.test-helpers.ts create mode 100644 apps/electron-backend/src/app/api/preload-performance-correlation.ts create mode 100644 apps/electron-backend/src/app/api/preload-performance-correlation.validation.spec.ts create mode 100644 libs/shared/interfaces/src/lib/preload-performance-marker.interface.ts diff --git a/AGENTS.md b/AGENTS.md index 014efff5b..f3cd7a990 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,6 +84,7 @@ IPTVNATOR_TRACE_STARTUP=1 nx serve electron-backend - `IPTVNATOR_TRACE_WINDOW=1` traces BrowserWindow lifecycle and unresponsive events - `IPTVNATOR_TRACE_PLAYER=1` traces external-player activity and bounded Embedded MPV runtime-probe stderr - `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console output into the Electron terminal + - `IPTVNATOR_PERF_CAPTURE=1` enables development/test-only, redacted preload IPC phase markers for refresh/DB benchmark correlation; benchmark tooling sets it explicitly, and production launches must leave it unset - `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only event-loop metrics in database and playlist-refresh worker responses; the performance benchmark sets it automatically, and production launches must leave it unset - Settings, portal request/response, and trace payloads must use diff --git a/CLAUDE.md b/CLAUDE.md index cb8cbbe18..72eed72c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -141,6 +141,7 @@ Useful narrower flags: - `IPTVNATOR_TRACE_WINDOW=1` traces BrowserWindow navigation/load lifecycle - `IPTVNATOR_TRACE_PLAYER=1` traces external-player activity and bounded Embedded MPV runtime-probe stderr - `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console logs into the Electron terminal +- `IPTVNATOR_PERF_CAPTURE=1` enables development/test-only, redacted preload IPC phase markers for refresh/DB benchmark correlation; benchmark tooling sets it explicitly, and production launches must leave it unset - `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only event-loop metrics in database and playlist-refresh worker responses; the performance benchmark sets it automatically, and production launches must leave it unset Settings, portal request/response, and trace payloads must use diff --git a/apps/electron-backend/src/app/api/main.preload.performance.contract.spec.ts b/apps/electron-backend/src/app/api/main.preload.performance.contract.spec.ts new file mode 100644 index 000000000..f2956075d --- /dev/null +++ b/apps/electron-backend/src/app/api/main.preload.performance.contract.spec.ts @@ -0,0 +1,318 @@ +import { + PRELOAD_PERFORMANCE_CORRELATION_STATE, + PRELOAD_PERFORMANCE_INVALID_REASON, + PRELOAD_PERFORMANCE_MARKER_CHANNEL, + PRELOAD_PERFORMANCE_METHOD, + type Playlist, + type PreloadPerformanceMarker, +} from '@iptvnator/shared/interfaces'; +import { + createPlaylist, + createPreloadPerformanceHarness, + createRefreshPayload, + expectFixedMarkers, + PRELOAD_ENVIRONMENT, + type PreloadPerformanceHarness, +} from './main.preload.performance.test-helpers'; + +describe('main preload performance marker contract', () => { + let harness: PreloadPerformanceHarness; + + beforeEach(() => { + harness = createPreloadPerformanceHarness(); + }); + + afterEach(() => { + harness.cleanup(); + }); + + async function loadPerformancePreload(): Promise { + await harness.load({ + [PRELOAD_ENVIRONMENT.PERF_CAPTURE]: '1', + }); + } + + it('preserves calls while emitting ordered, correlated, fixed, redacted markers', async () => { + const secrets = { + favorite: 'sentinel-favorite-secret', + filePath: '/sentinel/private/playlist.m3u', + host: 'sentinel.private.example', + item: 'sentinel-item-secret', + result: 'sentinel-result-secret', + title: 'sentinel-title-secret', + url: 'https://sentinel.example/private.m3u?token=secret', + }; + const payload = createRefreshPayload({ + filePath: secrets.filePath, + title: secrets.title, + trustedInsecureTlsHosts: [secrets.host], + url: secrets.url, + }); + const refreshedPlaylist = createPlaylist(payload.playlistId, { + favorites: [secrets.favorite], + items: [{ name: secrets.item }], + title: secrets.title, + url: secrets.url, + }); + const storedPlaylist = createPlaylist(payload.playlistId, { + filePath: secrets.filePath, + title: secrets.title, + }); + const upsertPlaylist = createPlaylist(payload.playlistId, { + favorites: [secrets.favorite], + items: [{ name: secrets.item }], + title: secrets.title, + }); + const upsertResult = { + secretResult: secrets.result, + success: true, + }; + await loadPerformancePreload(); + harness.ipcRenderer.invoke + .mockResolvedValueOnce(refreshedPlaylist) + .mockResolvedValueOnce(storedPlaylist) + .mockResolvedValueOnce(upsertResult); + const api = harness.getApi(); + + await expect(api.refreshPlaylist(payload)).resolves.toBe( + refreshedPlaylist + ); + await expect(api.dbGetAppPlaylist(payload.playlistId)).resolves.toBe( + storedPlaylist + ); + await expect(api.dbUpsertAppPlaylist(upsertPlaylist)).resolves.toBe( + upsertResult + ); + + expect(harness.ipcRenderer.invoke.mock.calls).toEqual([ + ['PLAYLIST:REFRESH', payload], + ['DB_GET_APP_PLAYLIST', payload.playlistId], + ['DB_UPSERT_APP_PLAYLIST', upsertPlaylist], + ]); + const markers = harness.getMarkers(); + expect(markers).toHaveLength(6); + expect( + markers.map(({ ipcCallId, method, phase }) => [ + ipcCallId, + method, + phase, + ]) + ).toEqual([ + [1, PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, 'start'], + [1, PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, 'success'], + [2, PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, 'start'], + [2, PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, 'success'], + [3, PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, 'start'], + [3, PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, 'success'], + ]); + expect(markers.map(({ correlationState }) => correlationState)).toEqual( + [ + PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + PRELOAD_PERFORMANCE_CORRELATION_STATE.COMPLETE, + ] + ); + expect(markers.map(({ operationId }) => operationId)).toEqual( + Array(6).fill(payload.operationId) + ); + expect(markers.map(({ playlistId }) => playlistId)).toEqual( + Array(6).fill(payload.playlistId) + ); + expect(markers.map(({ invalidReason }) => invalidReason)).toEqual( + Array(6).fill(null) + ); + expectFixedMarkers(markers); + + const markerSendOrders = harness.ipcRenderer.send.mock.calls.flatMap( + ([channel], index) => + channel === PRELOAD_PERFORMANCE_MARKER_CHANNEL + ? [harness.ipcRenderer.send.mock.invocationCallOrder[index]] + : [] + ); + const invokeOrders = + harness.ipcRenderer.invoke.mock.invocationCallOrder; + for (const [index, invokeOrder] of invokeOrders.entries()) { + expect(markerSendOrders[index * 2]).toBeLessThan(invokeOrder); + } + + const serializedMarkers = JSON.stringify(markers); + for (const secret of Object.values(secrets)) { + expect(serializedMarkers).not.toContain(secret); + } + }); + + it('preserves rejection identity and emits a redacted error', async () => { + const payload = createRefreshPayload({ + title: 'sentinel-rejection-title', + url: 'https://sentinel.example/rejected.m3u', + }); + const rejection = new Error('sentinel-rejection-error'); + await loadPerformancePreload(); + harness.ipcRenderer.invoke.mockRejectedValueOnce(rejection); + + await expect(harness.getApi().refreshPlaylist(payload)).rejects.toBe( + rejection + ); + + const markers = harness.getMarkers(); + expect(markerPhases(markers)).toEqual([ + [1, 'start'], + [1, 'error'], + ]); + expect(markers[1]).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.IPC_ERROR, + operationId: payload.operationId, + playlistId: payload.playlistId, + }); + expect(JSON.stringify(markers)).not.toContain(rejection.message); + expect(JSON.stringify(markers)).not.toContain(payload.title); + expect(JSON.stringify(markers)).not.toContain(payload.url); + }); + + it('preserves synchronous throw identity and emits a redacted error', async () => { + const synchronousError = new Error('sentinel-synchronous-error'); + const payload = createRefreshPayload(); + await loadPerformancePreload(); + harness.ipcRenderer.invoke.mockImplementationOnce(() => { + throw synchronousError; + }); + + let caughtError: unknown; + try { + harness.getApi().refreshPlaylist(payload); + } catch (error) { + caughtError = error; + } + + expect(caughtError).toBe(synchronousError); + const markers = harness.getMarkers(); + expect(markerPhases(markers)).toEqual([ + [1, 'start'], + [1, 'error'], + ]); + expect(markers[1]).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.IPC_ERROR, + }); + expect(JSON.stringify(markers)).not.toContain(synchronousError.message); + }); + + it('closes a structured refresh cancellation with a fixed reason', async () => { + const payload = createRefreshPayload(); + const cancelledResult = { + operationId: payload.operationId, + type: 'playlist-refresh-cancelled', + } as const; + await loadPerformancePreload(); + harness.ipcRenderer.invoke + .mockResolvedValueOnce(cancelledResult) + .mockResolvedValueOnce(null); + const api = harness.getApi(); + + await expect(api.refreshPlaylist(payload)).resolves.toBe( + cancelledResult + ); + await api.dbGetAppPlaylist(payload.playlistId); + + const markers = harness.getMarkers(); + expect(markers[1]).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.REFRESH_CANCELLED, + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: payload.operationId, + phase: 'success', + playlistId: payload.playlistId, + }); + expect(markers[2]).toMatchObject({ + correlationState: + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: null, + phase: 'start', + }); + }); + + it('uses the exact upsert _id and keeps one malformed reason in both phases', async () => { + const malformedPlaylist = { + ...createPlaylist('placeholder'), + _id: { + secret: 'sentinel-malformed-playlist-id', + }, + } as unknown as Playlist; + const result = { success: true }; + await loadPerformancePreload(); + harness.ipcRenderer.invoke.mockResolvedValueOnce(result); + + await expect( + harness.getApi().dbUpsertAppPlaylist(malformedPlaylist) + ).resolves.toBe(result); + expect(harness.ipcRenderer.invoke).toHaveBeenCalledWith( + 'DB_UPSERT_APP_PLAYLIST', + malformedPlaylist + ); + + const markers = harness.getMarkers(); + expect( + markers.map( + ({ correlationState, invalidReason, phase, playlistId }) => ({ + correlationState, + invalidReason, + phase, + playlistId, + }) + ) + ).toEqual([ + { + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: + PRELOAD_PERFORMANCE_INVALID_REASON.MALFORMED_IDENTIFIER, + phase: 'start', + playlistId: null, + }, + { + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: + PRELOAD_PERFORMANCE_INVALID_REASON.MALFORMED_IDENTIFIER, + phase: 'success', + playlistId: null, + }, + ]); + expect(markers[0].ipcCallId).toBe(markers[1].ipcCallId); + expect(JSON.stringify(markers)).not.toContain( + 'sentinel-malformed-playlist-id' + ); + }); + + it('keeps results unchanged when marker delivery throws', async () => { + const payload = createRefreshPayload(); + const result = createPlaylist(payload.playlistId); + await loadPerformancePreload(); + harness.ipcRenderer.invoke.mockResolvedValueOnce(result); + harness.ipcRenderer.send.mockImplementation((channel: string) => { + if (channel === PRELOAD_PERFORMANCE_MARKER_CHANNEL) { + throw new Error('marker-send-failed'); + } + }); + + await expect(harness.getApi().refreshPlaylist(payload)).resolves.toBe( + result + ); + expect( + harness.ipcRenderer.send.mock.calls.filter( + ([channel]) => channel === PRELOAD_PERFORMANCE_MARKER_CHANNEL + ) + ).toHaveLength(2); + }); +}); + +function markerPhases( + markers: PreloadPerformanceMarker[] +): [number, PreloadPerformanceMarker['phase']][] { + return markers.map(({ ipcCallId, phase }) => [ipcCallId, phase]); +} diff --git a/apps/electron-backend/src/app/api/main.preload.performance.gates.spec.ts b/apps/electron-backend/src/app/api/main.preload.performance.gates.spec.ts new file mode 100644 index 000000000..36b8a2b1b --- /dev/null +++ b/apps/electron-backend/src/app/api/main.preload.performance.gates.spec.ts @@ -0,0 +1,149 @@ +import { + PRELOAD_PERFORMANCE_CORRELATION_STATE, + PRELOAD_PERFORMANCE_MARKER_CHANNEL, + PRELOAD_PERFORMANCE_METHOD, + type Settings, +} from '@iptvnator/shared/interfaces'; +import { DEBUG_TRACE_EVENT_CHANNEL } from '../services/debug-trace'; +import { + createPreloadPerformanceHarness, + PRELOAD_ENVIRONMENT, + type PreloadPerformanceHarness, +} from './main.preload.performance.test-helpers'; + +describe('main preload performance marker gates', () => { + let harness: PreloadPerformanceHarness; + + beforeEach(() => { + harness = createPreloadPerformanceHarness(); + }); + + afterEach(() => { + harness.cleanup(); + }); + + it.each([undefined, '', '0', 'false', 'no', 'off'])( + 'emits no marker when the performance flag is %s', + async (flagValue) => { + await harness.load( + flagValue === undefined + ? {} + : { + [PRELOAD_ENVIRONMENT.PERF_CAPTURE]: flagValue, + } + ); + + await harness.getApi().dbGetAppPlaylist('playlist-1'); + + expect(harness.getMarkers()).toEqual([]); + } + ); + + it.each(['1', ' TRUE ', 'yes', 'On'])( + 'accepts the existing true flag value %s', + async (flagValue) => { + await harness.load({ + [PRELOAD_ENVIRONMENT.PERF_CAPTURE]: flagValue, + }); + + await harness.getApi().dbGetAppPlaylist('playlist-1'); + + expect( + harness.getMarkers().map(({ correlationState, phase }) => ({ + correlationState, + phase, + })) + ).toEqual([ + { + correlationState: + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, + phase: 'start', + }, + { + correlationState: + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, + phase: 'success', + }, + ]); + } + ); + + it('keeps trace-only calls on the existing debug channel', async () => { + await harness.load({ + [PRELOAD_ENVIRONMENT.TRACE_IPC]: '1', + }); + + await harness.getApi().dbGetAppPlaylist('playlist-1'); + + expect( + harness + .getSentPayloads< + Record + >(DEBUG_TRACE_EVENT_CHANNEL) + .map(({ method, phase }) => ({ method, phase })) + ).toEqual([ + { + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + phase: 'start', + }, + { + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + phase: 'success', + }, + ]); + expect(harness.getMarkers()).toEqual([]); + }); + + it('keeps debug tracing unchanged when both gates are enabled', async () => { + await harness.load({ + [PRELOAD_ENVIRONMENT.PERF_CAPTURE]: '1', + [PRELOAD_ENVIRONMENT.TRACE_IPC]: '1', + }); + + await harness.getApi().dbGetAppPlaylist('playlist-1'); + + expect( + harness + .getSentPayloads< + Record + >(DEBUG_TRACE_EVENT_CHANNEL) + .map(({ method, phase }) => ({ method, phase })) + ).toEqual([ + { + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + phase: 'start', + }, + { + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + phase: 'success', + }, + ]); + expect( + harness.getSentPayloads(PRELOAD_PERFORMANCE_MARKER_CHANNEL).length + ).toBe(2); + }); + + it('does not inspect or mark non-target calls in performance-only mode', async () => { + const result = { success: true }; + const settings = Object.defineProperty({}, 'sentinel', { + enumerable: true, + get: () => { + throw new Error('non-target arguments must stay opaque'); + }, + }) as Partial; + await harness.load({ + [PRELOAD_ENVIRONMENT.PERF_CAPTURE]: '1', + }); + harness.ipcRenderer.invoke.mockResolvedValueOnce(result); + + await expect(harness.getApi().updateSettings(settings)).resolves.toBe( + result + ); + + expect(harness.ipcRenderer.invoke).toHaveBeenCalledWith( + 'SETTINGS_UPDATE', + settings + ); + expect(harness.getMarkers()).toEqual([]); + }); +}); diff --git a/apps/electron-backend/src/app/api/main.preload.performance.test-helpers.ts b/apps/electron-backend/src/app/api/main.preload.performance.test-helpers.ts new file mode 100644 index 000000000..69c8a7e0f --- /dev/null +++ b/apps/electron-backend/src/app/api/main.preload.performance.test-helpers.ts @@ -0,0 +1,162 @@ +import type { + ElectronBridgeApi, + Playlist, + PlaylistRefreshPayload, + PreloadPerformanceMarker, +} from '@iptvnator/shared/interfaces'; + +export const PRELOAD_ENVIRONMENT = { + PERF_CAPTURE: 'IPTVNATOR_PERF_CAPTURE', + TRACE_IPC: 'IPTVNATOR_TRACE_IPC', + TRACE_STARTUP: 'IPTVNATOR_TRACE_STARTUP', +} as const; + +type PreloadEnvironmentName = + (typeof PRELOAD_ENVIRONMENT)[keyof typeof PRELOAD_ENVIRONMENT]; + +type PreloadEnvironment = Partial>; + +export type ExposedElectronApi = ElectronBridgeApi & + Record unknown>; + +export interface MockIpcRenderer { + invoke: jest.Mock; + off: jest.Mock; + on: jest.Mock; + send: jest.Mock; +} + +export interface PreloadPerformanceHarness { + cleanup: () => void; + getApi: () => ExposedElectronApi; + getMarkers: () => PreloadPerformanceMarker[]; + getSentPayloads: (channel: string) => T[]; + ipcRenderer: MockIpcRenderer; + load: (environment?: PreloadEnvironment) => Promise; +} + +const MARKER_KEYS = [ + 'correlationState', + 'invalidReason', + 'ipcCallId', + 'method', + 'operationId', + 'phase', + 'playlistId', + 'sourceEpochMs', +] as const; + +export function createPlaylist( + playlistId: string, + overrides: Partial = {} +): Playlist { + return { + _id: playlistId, + autoRefresh: false, + count: 1, + importDate: '2026-07-26T00:00:00.000Z', + lastUsage: '2026-07-26T00:00:00.000Z', + title: 'Performance fixture', + ...overrides, + }; +} + +export function createRefreshPayload( + overrides: Partial = {} +): PlaylistRefreshPayload { + return { + operationId: 'operation-1', + playlistId: 'playlist-1', + title: 'Performance fixture', + ...overrides, + }; +} + +export function expectFixedMarkers(markers: PreloadPerformanceMarker[]): void { + for (const [index, marker] of markers.entries()) { + expect(Object.keys(marker).sort()).toEqual([...MARKER_KEYS].sort()); + expect(Number.isFinite(marker.sourceEpochMs)).toBe(true); + if (index > 0) { + expect(marker.sourceEpochMs).toBeGreaterThanOrEqual( + markers[index - 1].sourceEpochMs + ); + } + } +} + +export function createPreloadPerformanceHarness(): PreloadPerformanceHarness { + const originalEnvironment = new Map< + PreloadEnvironmentName, + string | undefined + >( + Object.values(PRELOAD_ENVIRONMENT).map((name) => [ + name, + process.env[name], + ]) + ); + let exposedApi: ExposedElectronApi | null = null; + const ipcRenderer: MockIpcRenderer = { + invoke: jest.fn(), + off: jest.fn(), + on: jest.fn(), + send: jest.fn(), + }; + + function getSentPayloads(channel: string): T[] { + return ipcRenderer.send.mock.calls + .filter(([sentChannel]) => sentChannel === channel) + .map(([, payload]) => payload as T); + } + + return { + cleanup: () => { + for (const [name, value] of originalEnvironment) { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } + } + jest.dontMock('electron'); + }, + getApi: () => { + if (!exposedApi) { + throw new Error('Expected preload API to be exposed'); + } + return exposedApi; + }, + getMarkers: () => + getSentPayloads( + 'IPTVNATOR_PERF_CAPTURE_MARKER' + ), + getSentPayloads, + ipcRenderer, + load: async (environment = {}) => { + jest.resetModules(); + for (const name of Object.values(PRELOAD_ENVIRONMENT)) { + delete process.env[name]; + } + Object.assign(process.env, environment); + exposedApi = null; + ipcRenderer.invoke.mockReset().mockResolvedValue({ success: true }); + ipcRenderer.off.mockReset(); + ipcRenderer.on.mockReset(); + ipcRenderer.send.mockReset(); + jest.doMock('electron', () => ({ + contextBridge: { + exposeInMainWorld: jest.fn( + (_name: string, api: ExposedElectronApi) => { + exposedApi = api; + } + ), + }, + ipcRenderer, + webUtils: { + getPathForFile: jest.fn(), + }, + })); + + await import('./main.preload'); + }, + }; +} diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index bd078b223..e93d611dd 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -11,6 +11,10 @@ import { attachEmbeddedMpvFrameView, detachEmbeddedMpvFrameView, } from './embedded-mpv-frame-pump'; +import { + createPreloadPerformanceCapture, + toPreloadPerformanceTargetMethod, +} from './preload-performance-capture'; import type { EmbeddedMpvBounds, EmbeddedMpvRecordingStartOptions, @@ -48,6 +52,7 @@ import type { } from '@iptvnator/shared/interfaces'; import { DEBUG_TRACE_EVENT_CHANNEL, + isPerformanceCaptureEnabled, isRendererApiTraceEnabled, roundTraceDuration, summarizeForTrace, @@ -72,6 +77,11 @@ const dbSaveContentProgressListeners = new Set< >(); const shouldTraceRendererApi = isRendererApiTraceEnabled(); +const shouldCapturePerformance = isPerformanceCaptureEnabled(); +const preloadPerformanceCapture = createPreloadPerformanceCapture( + shouldCapturePerformance, + (channel, marker) => ipcRenderer.send(channel, marker) +); function emitRendererTrace(payload: { method: string; @@ -89,7 +99,7 @@ function emitRendererTrace(payload: { } function wrapElectronApi(api: T): T { - if (!shouldTraceRendererApi) { + if (!shouldTraceRendererApi && !shouldCapturePerformance) { return api; } @@ -98,7 +108,9 @@ function wrapElectronApi(api: T): T { if ( typeof value !== 'function' || name.startsWith('on') || - name.startsWith('remove') + name.startsWith('remove') || + (!shouldTraceRendererApi && + !toPreloadPerformanceTargetMethod(name)) ) { return [name, value]; } @@ -108,13 +120,20 @@ function wrapElectronApi(api: T): T { return [ name, (...args: unknown[]) => { - const startedAt = - globalThis.performance?.now?.() ?? Date.now(); - emitRendererTrace({ - args: summarizeForTrace(args), - method: name, - phase: 'start', - }); + const startedAt = shouldTraceRendererApi + ? (globalThis.performance?.now?.() ?? Date.now()) + : 0; + if (shouldTraceRendererApi) { + emitRendererTrace({ + args: summarizeForTrace(args), + method: name, + phase: 'start', + }); + } + const performanceCall = preloadPerformanceCapture.start( + name, + args + ); try { const result = original(...args); @@ -126,54 +145,74 @@ function wrapElectronApi(api: T): T { ) { return (result as Promise) .then((resolvedValue) => { - emitRendererTrace({ - durationMs: roundTraceDuration( - (globalThis.performance?.now?.() ?? - Date.now()) - startedAt - ), - method: name, - phase: 'success', - result: summarizeForTrace( - resolvedValue - ), - }); + if (shouldTraceRendererApi) { + emitRendererTrace({ + durationMs: roundTraceDuration( + (globalThis.performance?.now?.() ?? + Date.now()) - startedAt + ), + method: name, + phase: 'success', + result: summarizeForTrace( + resolvedValue + ), + }); + } + preloadPerformanceCapture.success( + performanceCall, + resolvedValue + ); return resolvedValue; }) .catch((error: unknown) => { - emitRendererTrace({ - durationMs: roundTraceDuration( - (globalThis.performance?.now?.() ?? - Date.now()) - startedAt - ), - error: summarizeForTrace(error), - method: name, - phase: 'error', - }); + if (shouldTraceRendererApi) { + emitRendererTrace({ + durationMs: roundTraceDuration( + (globalThis.performance?.now?.() ?? + Date.now()) - startedAt + ), + error: summarizeForTrace(error), + method: name, + phase: 'error', + }); + } + preloadPerformanceCapture.error( + performanceCall + ); throw error; }); } - emitRendererTrace({ - durationMs: roundTraceDuration( - (globalThis.performance?.now?.() ?? - Date.now()) - startedAt - ), - method: name, - phase: 'success', - result: summarizeForTrace(result), - }); + if (shouldTraceRendererApi) { + emitRendererTrace({ + durationMs: roundTraceDuration( + (globalThis.performance?.now?.() ?? + Date.now()) - startedAt + ), + method: name, + phase: 'success', + result: summarizeForTrace(result), + }); + } + preloadPerformanceCapture.success( + performanceCall, + result + ); return result; } catch (error) { - emitRendererTrace({ - durationMs: roundTraceDuration( - (globalThis.performance?.now?.() ?? - Date.now()) - startedAt - ), - error: summarizeForTrace(error), - method: name, - phase: 'error', - }); + if (shouldTraceRendererApi) { + emitRendererTrace({ + durationMs: roundTraceDuration( + (globalThis.performance?.now?.() ?? + Date.now()) - startedAt + ), + error: summarizeForTrace(error), + method: name, + phase: 'error', + }); + } + preloadPerformanceCapture.error(performanceCall); throw error; } }, @@ -530,8 +569,16 @@ const electronApi: ElectronBridgeApi = { ipcRenderer.invoke('EPG_MAPPING_GET', { channelKey }), getEpgMappingsBatch: (channelKeys: string[]) => ipcRenderer.invoke('EPG_MAPPING_GET_BATCH', { channelKeys }), - setEpgMapping: (channelKey: string, epgChannelId: string, playlistId?: string) => - ipcRenderer.invoke('EPG_MAPPING_SET', { channelKey, epgChannelId, playlistId }), + setEpgMapping: ( + channelKey: string, + epgChannelId: string, + playlistId?: string + ) => + ipcRenderer.invoke('EPG_MAPPING_SET', { + channelKey, + epgChannelId, + playlistId, + }), deleteEpgMapping: (channelKey: string) => ipcRenderer.invoke('EPG_MAPPING_DELETE', { channelKey }), searchEpgChannels: (searchTerm: string, limit?: number) => diff --git a/apps/electron-backend/src/app/api/preload-performance-capture.spec.ts b/apps/electron-backend/src/app/api/preload-performance-capture.spec.ts new file mode 100644 index 000000000..9d900e13a --- /dev/null +++ b/apps/electron-backend/src/app/api/preload-performance-capture.spec.ts @@ -0,0 +1,50 @@ +import { + PRELOAD_PERFORMANCE_MARKER_CHANNEL, + PRELOAD_PERFORMANCE_METHOD, + type PreloadPerformanceMarker, +} from '@iptvnator/shared/interfaces'; +import { createPreloadPerformanceCapture } from './preload-performance-capture'; + +describe('preload performance capture timestamps', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('uses fractional monotonic time on top of the epoch origin', () => { + const markers: PreloadPerformanceMarker[] = []; + const monotonicNow = jest + .spyOn(globalThis.performance, 'now') + .mockReturnValueOnce(10.125) + .mockReturnValueOnce(10.375); + const capture = createPreloadPerformanceCapture( + true, + (channel, marker) => { + expect(channel).toBe(PRELOAD_PERFORMANCE_MARKER_CHANNEL); + markers.push(marker); + } + ); + + const call = capture.start( + PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + [ + { + operationId: 'operation-1', + playlistId: 'playlist-1', + }, + ] + ); + capture.success(call, {}); + + expect(monotonicNow).toHaveBeenCalledTimes(2); + expect(markers).toHaveLength(2); + expect( + markers[0].sourceEpochMs - globalThis.performance.timeOrigin + ).toBeCloseTo(10.125, 3); + expect( + markers[1].sourceEpochMs - globalThis.performance.timeOrigin + ).toBeCloseTo(10.375, 3); + expect(markers[1].sourceEpochMs).toBeGreaterThan( + markers[0].sourceEpochMs + ); + }); +}); diff --git a/apps/electron-backend/src/app/api/preload-performance-capture.ts b/apps/electron-backend/src/app/api/preload-performance-capture.ts new file mode 100644 index 000000000..c6543d3d3 --- /dev/null +++ b/apps/electron-backend/src/app/api/preload-performance-capture.ts @@ -0,0 +1,191 @@ +import { + isPlaylistRefreshCancelledResult, + PRELOAD_PERFORMANCE_MARKER_CHANNEL, + PRELOAD_PERFORMANCE_METHOD, + type PreloadPerformanceMarker, + type PreloadPerformanceMethod, + type PreloadPerformancePhase, +} from '@iptvnator/shared/interfaces'; +import { + advancePreloadPerformanceCorrelation, + createPreloadPerformanceCorrelationState, + normalizePreloadPerformanceIdentifier, + type PreloadPerformanceCorrelationState, +} from './preload-performance-correlation'; + +export interface PreloadPerformanceCallContext { + ipcCallId: number; + method: PreloadPerformanceMethod; + operationId: string | null; + playlistId: string | null; +} + +export interface PreloadPerformanceCapture { + error: (call: PreloadPerformanceCallContext | null) => void; + start: ( + methodName: string, + args: readonly unknown[] + ) => PreloadPerformanceCallContext | null; + success: ( + call: PreloadPerformanceCallContext | null, + result: unknown + ) => void; +} + +type MarkerSender = ( + channel: typeof PRELOAD_PERFORMANCE_MARKER_CHANNEL, + marker: PreloadPerformanceMarker +) => void; + +function readProperty(value: unknown, property: string): unknown { + if ( + (typeof value !== 'object' || value === null) && + typeof value !== 'function' + ) { + return undefined; + } + + try { + return Reflect.get(value, property); + } catch { + return undefined; + } +} + +export function toPreloadPerformanceTargetMethod( + methodName: string +): PreloadPerformanceMethod | null { + switch (methodName) { + case PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST: + case PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST: + case PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST: + return methodName; + default: + return null; + } +} + +function readHighResolutionEpochMs(): number { + const timeOrigin = globalThis.performance?.timeOrigin; + const monotonicNow = globalThis.performance?.now?.(); + const highResolutionEpoch = + typeof timeOrigin === 'number' && typeof monotonicNow === 'number' + ? timeOrigin + monotonicNow + : Number.NaN; + + return Number.isFinite(highResolutionEpoch) + ? highResolutionEpoch + : Date.now(); +} + +function extractIdentifiers( + method: PreloadPerformanceMethod, + args: readonly unknown[] +): Pick { + if (method === PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST) { + const payload = args[0]; + return { + operationId: normalizePreloadPerformanceIdentifier( + readProperty(payload, 'operationId') + ), + playlistId: normalizePreloadPerformanceIdentifier( + readProperty(payload, 'playlistId') + ), + }; + } + + if (method === PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST) { + return { + operationId: null, + playlistId: normalizePreloadPerformanceIdentifier(args[0]), + }; + } + + return { + operationId: null, + playlistId: normalizePreloadPerformanceIdentifier( + readProperty(args[0], '_id') + ), + }; +} + +export function createPreloadPerformanceCapture( + enabled: boolean, + sendMarker: MarkerSender +): PreloadPerformanceCapture { + let correlationState: PreloadPerformanceCorrelationState = + createPreloadPerformanceCorrelationState(); + let lastSourceEpochMs = 0; + let nextIpcCallId = 1; + + function emit( + call: PreloadPerformanceCallContext, + phase: PreloadPerformancePhase, + refreshCancelled = false + ): void { + try { + const transition = advancePreloadPerformanceCorrelation( + correlationState, + { + ...call, + phase, + refreshCancelled, + } + ); + correlationState = transition.state; + const sourceEpochMs = Math.max( + readHighResolutionEpochMs(), + lastSourceEpochMs + ); + lastSourceEpochMs = sourceEpochMs; + sendMarker(PRELOAD_PERFORMANCE_MARKER_CHANNEL, { + ...transition.marker, + sourceEpochMs, + }); + } catch { + // Performance instrumentation must never affect the bridge call. + } + } + + return { + error: (call) => { + if (call) { + emit(call, 'error'); + } + }, + start: (methodName, args) => { + if (!enabled) { + return null; + } + + const method = toPreloadPerformanceTargetMethod(methodName); + if (!method) { + return null; + } + + const call: PreloadPerformanceCallContext = { + ipcCallId: nextIpcCallId, + method, + ...extractIdentifiers(method, args), + }; + nextIpcCallId += 1; + emit(call, 'start'); + return call; + }, + success: (call, result) => { + if (!call) { + return; + } + + let refreshCancelled = false; + if (call.method === PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST) { + try { + refreshCancelled = isPlaylistRefreshCancelledResult(result); + } catch { + refreshCancelled = false; + } + } + emit(call, 'success', refreshCancelled); + }, + }; +} diff --git a/apps/electron-backend/src/app/api/preload-performance-correlation.complete.ts b/apps/electron-backend/src/app/api/preload-performance-correlation.complete.ts new file mode 100644 index 000000000..26e79ef46 --- /dev/null +++ b/apps/electron-backend/src/app/api/preload-performance-correlation.complete.ts @@ -0,0 +1,235 @@ +import { + PRELOAD_PERFORMANCE_CORRELATION_STATE, + PRELOAD_PERFORMANCE_INVALID_REASON, + PRELOAD_PERFORMANCE_METHOD, +} from '@iptvnator/shared/interfaces'; +import { + PRELOAD_PERFORMANCE_SEQUENCE_STAGE, + closeInvalidPreloadPerformanceSequenceWhenSettled, + createPreloadPerformanceMarkerMetadata, + invalidatePreloadPerformanceSequence, + type PreloadPerformanceCall, + type PreloadPerformanceCorrelationEvent, + type PreloadPerformanceMarkerMetadata, + type PreloadPerformanceSequence, +} from './preload-performance-correlation.model'; + +function invalidCompletion( + calls: Map, + sequences: Map, + event: PreloadPerformanceCorrelationEvent, + call: PreloadPerformanceCall +): PreloadPerformanceMarkerMetadata { + const invalidSequence = + call.playlistId === null + ? undefined + : invalidatePreloadPerformanceSequence( + calls, + sequences, + call.playlistId, + PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER + ); + calls.delete(event.ipcCallId); + closeInvalidPreloadPerformanceSequenceWhenSettled( + calls, + sequences, + call.playlistId + ); + return createPreloadPerformanceMarkerMetadata( + event, + call.playlistId, + invalidSequence?.operationId ?? call.operationId, + PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidSequence?.invalidReason ?? + PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER + ); +} + +function completeCorrelatedCall( + calls: Map, + sequences: Map, + event: PreloadPerformanceCorrelationEvent, + call: PreloadPerformanceCall & { playlistId: string } +): PreloadPerformanceMarkerMetadata { + const sequence = sequences.get(call.playlistId); + const expectedStage = + event.method === PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST + ? PRELOAD_PERFORMANCE_SEQUENCE_STAGE.REFRESH_STARTED + : event.method === PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST + ? PRELOAD_PERFORMANCE_SEQUENCE_STAGE.DB_GET_STARTED + : PRELOAD_PERFORMANCE_SEQUENCE_STAGE.DB_UPSERT_STARTED; + if ( + !sequence || + sequence.stage !== expectedStage || + sequence.activeCallId !== event.ipcCallId || + sequence.operationId !== call.operationId + ) { + return invalidCompletion(calls, sequences, event, call); + } + + if (event.phase === 'error') { + const invalidSequence = invalidatePreloadPerformanceSequence( + calls, + sequences, + call.playlistId, + PRELOAD_PERFORMANCE_INVALID_REASON.IPC_ERROR + ); + calls.delete(event.ipcCallId); + closeInvalidPreloadPerformanceSequenceWhenSettled( + calls, + sequences, + call.playlistId + ); + return createPreloadPerformanceMarkerMetadata( + event, + call.playlistId, + sequence.operationId, + PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidSequence?.invalidReason ?? + PRELOAD_PERFORMANCE_INVALID_REASON.IPC_ERROR + ); + } + + if ( + event.method === PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST && + event.refreshCancelled === true + ) { + sequences.delete(call.playlistId); + calls.delete(event.ipcCallId); + return createPreloadPerformanceMarkerMetadata( + event, + call.playlistId, + sequence.operationId, + PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + PRELOAD_PERFORMANCE_INVALID_REASON.REFRESH_CANCELLED + ); + } + + calls.delete(event.ipcCallId); + if (event.method === PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST) { + sequences.set(call.playlistId, { + ...sequence, + activeCallId: null, + stage: PRELOAD_PERFORMANCE_SEQUENCE_STAGE.REFRESH_SUCCEEDED, + }); + return createPreloadPerformanceMarkerMetadata( + event, + call.playlistId, + sequence.operationId, + PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + null + ); + } + + if (event.method === PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST) { + sequences.set(call.playlistId, { + ...sequence, + activeCallId: null, + stage: PRELOAD_PERFORMANCE_SEQUENCE_STAGE.DB_GET_SUCCEEDED, + }); + return createPreloadPerformanceMarkerMetadata( + event, + call.playlistId, + sequence.operationId, + PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + null + ); + } + + sequences.delete(call.playlistId); + return createPreloadPerformanceMarkerMetadata( + event, + call.playlistId, + sequence.operationId, + PRELOAD_PERFORMANCE_CORRELATION_STATE.COMPLETE, + null + ); +} + +export function completePreloadPerformanceCall( + calls: Map, + sequences: Map, + event: PreloadPerformanceCorrelationEvent, + playlistId: string | null, + operationId: string | null +): PreloadPerformanceMarkerMetadata { + const call = calls.get(event.ipcCallId); + if (!call) { + const sequence = playlistId ? sequences.get(playlistId) : undefined; + const invalidSequence = playlistId + ? invalidatePreloadPerformanceSequence( + calls, + sequences, + playlistId, + PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER + ) + : undefined; + const invalidReason = + invalidSequence?.invalidReason ?? + PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER; + closeInvalidPreloadPerformanceSequenceWhenSettled( + calls, + sequences, + playlistId + ); + return createPreloadPerformanceMarkerMetadata( + event, + playlistId, + invalidSequence?.operationId ?? sequence?.operationId ?? null, + PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason + ); + } + + const identityMatches = + call.method === event.method && + call.playlistId === playlistId && + (event.method !== PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST || + call.expectedOperationId === operationId); + if (!identityMatches) { + return invalidCompletion(calls, sequences, event, call); + } + + if ( + call.correlationState === + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED + ) { + calls.delete(event.ipcCallId); + return createPreloadPerformanceMarkerMetadata( + event, + call.playlistId, + null, + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, + null + ); + } + + if ( + call.correlationState === PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID + ) { + calls.delete(event.ipcCallId); + closeInvalidPreloadPerformanceSequenceWhenSettled( + calls, + sequences, + call.playlistId + ); + return createPreloadPerformanceMarkerMetadata( + event, + call.playlistId, + call.operationId, + PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + call.invalidReason ?? + PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER + ); + } + + if (call.playlistId === null) { + return invalidCompletion(calls, sequences, event, call); + } + return completeCorrelatedCall( + calls, + sequences, + event, + call as PreloadPerformanceCall & { playlistId: string } + ); +} diff --git a/apps/electron-backend/src/app/api/preload-performance-correlation.completion.spec.ts b/apps/electron-backend/src/app/api/preload-performance-correlation.completion.spec.ts new file mode 100644 index 000000000..97f13f081 --- /dev/null +++ b/apps/electron-backend/src/app/api/preload-performance-correlation.completion.spec.ts @@ -0,0 +1,314 @@ +import { + PRELOAD_PERFORMANCE_CORRELATION_STATE, + PRELOAD_PERFORMANCE_INVALID_REASON, + PRELOAD_PERFORMANCE_METHOD, +} from '@iptvnator/shared/interfaces'; +import { + createCorrelationHarness, + OPERATION_ONE, + OPERATION_TWO, + PLAYLIST_ONE, + PLAYLIST_TWO, + type CorrelationHarness, +} from './preload-performance-correlation.test-helpers'; + +describe('preload performance marker correlation completions', () => { + let advance: CorrelationHarness['advance']; + let reset: CorrelationHarness['reset']; + + beforeEach(() => { + ({ advance, reset } = createCorrelationHarness()); + }); + + it.each(['success', 'error'] as const)( + 'rejects a %s completion with the wrong call ID', + (phase) => { + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + const mismatchedCompletion = advance({ + ipcCallId: 99, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase, + playlistId: PLAYLIST_ONE, + }); + const originalCompletion = advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + + expect(mismatchedCompletion).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER, + }); + expect(originalCompletion).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER, + }); + } + ); + + it('rejects a refresh completion whose playlist or operation ID changed', () => { + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + const changedOperation = advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_TWO, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + + expect(changedOperation).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER, + }); + + reset(); + advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + const changedPlaylist = advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_TWO, + }); + + expect(changedPlaylist).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER, + }); + }); + + it('rejects a completion whose method differs from the started call', () => { + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + const wrongMethod = advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: null, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + const restart = advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_TWO, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + expect(wrongMethod).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER, + }); + expect(restart.correlationState).toBe( + PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED + ); + }); + + it('rejects a duplicate completion instead of advancing the chain twice', () => { + const refresh = { + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + playlistId: PLAYLIST_ONE, + } as const; + advance({ ...refresh, phase: 'start' }); + advance({ ...refresh, phase: 'success' }); + + const duplicate = advance({ ...refresh, phase: 'success' }); + const laterGet = advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: null, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + expect(duplicate).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER, + }); + expect(laterGet).toMatchObject({ + correlationState: + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, + invalidReason: null, + }); + }); + + it('rejects a database completion for a different playlist', () => { + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: null, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + const changedPlaylist = advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: null, + phase: 'success', + playlistId: PLAYLIST_TWO, + }); + const laterUpsert = advance({ + ipcCallId: 3, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId: null, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + expect(changedPlaylist).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER, + }); + expect(laterUpsert).toMatchObject({ + correlationState: + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, + invalidReason: null, + }); + }); + + it('invalidates a correlated sequence when a target call errors', () => { + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: null, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + const error = advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: null, + phase: 'error', + playlistId: PLAYLIST_ONE, + }); + const laterUpsert = advance({ + ipcCallId: 3, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId: null, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + const restart = advance({ + ipcCallId: 4, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_TWO, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + expect(error).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.IPC_ERROR, + operationId: OPERATION_ONE, + }); + expect(laterUpsert).toMatchObject({ + correlationState: + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, + invalidReason: null, + }); + expect(restart.correlationState).toBe( + PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED + ); + }); + + it('closes a cancelled refresh without awaiting database persistence', () => { + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + const cancelled = advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + refreshCancelled: true, + }); + const laterGet = advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: null, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + expect(cancelled).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.REFRESH_CANCELLED, + operationId: OPERATION_ONE, + }); + expect(laterGet).toMatchObject({ + correlationState: + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, + invalidReason: null, + operationId: null, + }); + }); +}); diff --git a/apps/electron-backend/src/app/api/preload-performance-correlation.model.ts b/apps/electron-backend/src/app/api/preload-performance-correlation.model.ts new file mode 100644 index 000000000..fe4ec4711 --- /dev/null +++ b/apps/electron-backend/src/app/api/preload-performance-correlation.model.ts @@ -0,0 +1,178 @@ +import { + PRELOAD_PERFORMANCE_CORRELATION_STATE, + type PreloadPerformanceCorrelationStateName, + type PreloadPerformanceInvalidReason, + type PreloadPerformanceMarker, + type PreloadPerformanceMethod, + type PreloadPerformancePhase, +} from '@iptvnator/shared/interfaces'; + +const MAX_IDENTIFIER_LENGTH = 128; +const SAFE_IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/; + +export const PRELOAD_PERFORMANCE_SEQUENCE_STAGE = { + DB_GET_STARTED: 'db-get-started', + DB_GET_SUCCEEDED: 'db-get-succeeded', + DB_UPSERT_STARTED: 'db-upsert-started', + INVALID: 'invalid', + REFRESH_STARTED: 'refresh-started', + REFRESH_SUCCEEDED: 'refresh-succeeded', +} as const; + +export type PreloadPerformanceSequenceStage = + (typeof PRELOAD_PERFORMANCE_SEQUENCE_STAGE)[keyof typeof PRELOAD_PERFORMANCE_SEQUENCE_STAGE]; + +export interface PreloadPerformanceSequence { + activeCallId: number | null; + invalidReason: PreloadPerformanceInvalidReason | null; + operationId: string; + stage: PreloadPerformanceSequenceStage; +} + +export interface PreloadPerformanceCall { + correlationState: PreloadPerformanceCorrelationStateName; + expectedOperationId: string | null; + invalidReason: PreloadPerformanceInvalidReason | null; + method: PreloadPerformanceMethod; + operationId: string | null; + playlistId: string | null; +} + +export interface PreloadPerformanceCorrelationEvent { + ipcCallId: number; + method: PreloadPerformanceMethod; + operationId: unknown; + phase: PreloadPerformancePhase; + playlistId: unknown; + refreshCancelled?: boolean; +} + +export interface PreloadPerformanceCorrelationState { + readonly calls: ReadonlyMap; + readonly sequences: ReadonlyMap; +} + +export interface PreloadPerformanceCorrelationResult { + marker: PreloadPerformanceMarkerMetadata; + state: PreloadPerformanceCorrelationState; +} + +export type PreloadPerformanceMarkerMetadata = Omit< + PreloadPerformanceMarker, + 'sourceEpochMs' +>; + +export function normalizePreloadPerformanceIdentifier( + value: unknown +): string | null { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > MAX_IDENTIFIER_LENGTH || + value !== value.trim() || + !SAFE_IDENTIFIER_PATTERN.test(value) + ) { + return null; + } + + return value; +} + +export function createPreloadPerformanceMarkerMetadata( + event: PreloadPerformanceCorrelationEvent, + playlistId: string | null, + operationId: string | null, + correlationState: PreloadPerformanceCorrelationStateName, + invalidReason: PreloadPerformanceInvalidReason | null +): PreloadPerformanceMarkerMetadata { + return { + correlationState, + invalidReason, + ipcCallId: event.ipcCallId, + method: event.method, + operationId, + phase: event.phase, + playlistId, + }; +} + +export function createInvalidPreloadPerformanceCall( + event: PreloadPerformanceCorrelationEvent, + playlistId: string | null, + operationId: string | null, + invalidReason: PreloadPerformanceInvalidReason, + expectedOperationId = operationId +): PreloadPerformanceCall { + return { + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + expectedOperationId, + invalidReason, + method: event.method, + operationId, + playlistId, + }; +} + +export function invalidatePreloadPerformanceSequence( + calls: Map, + sequences: Map, + playlistId: string, + reason: PreloadPerformanceInvalidReason +): PreloadPerformanceSequence | undefined { + const sequence = sequences.get(playlistId); + if (!sequence) { + return undefined; + } + + const invalidReason = + sequence.stage === PRELOAD_PERFORMANCE_SEQUENCE_STAGE.INVALID + ? (sequence.invalidReason ?? reason) + : reason; + const invalidSequence: PreloadPerformanceSequence = { + ...sequence, + activeCallId: null, + invalidReason, + stage: PRELOAD_PERFORMANCE_SEQUENCE_STAGE.INVALID, + }; + sequences.set(playlistId, invalidSequence); + + for (const [callId, call] of calls) { + if ( + call.playlistId === playlistId && + call.correlationState === + PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED + ) { + calls.set(callId, { + ...call, + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason, + }); + } + } + + return invalidSequence; +} + +export function closeInvalidPreloadPerformanceSequenceWhenSettled( + calls: ReadonlyMap, + sequences: Map, + playlistId: string | null +): void { + if ( + playlistId === null || + sequences.get(playlistId)?.stage !== + PRELOAD_PERFORMANCE_SEQUENCE_STAGE.INVALID + ) { + return; + } + + const hasInvalidCall = Array.from(calls.values()).some( + (call) => + call.playlistId === playlistId && + call.correlationState === + PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID + ); + if (!hasInvalidCall) { + sequences.delete(playlistId); + } +} diff --git a/apps/electron-backend/src/app/api/preload-performance-correlation.sequence.spec.ts b/apps/electron-backend/src/app/api/preload-performance-correlation.sequence.spec.ts new file mode 100644 index 000000000..266c1c986 --- /dev/null +++ b/apps/electron-backend/src/app/api/preload-performance-correlation.sequence.spec.ts @@ -0,0 +1,338 @@ +import { + PRELOAD_PERFORMANCE_CORRELATION_STATE, + PRELOAD_PERFORMANCE_INVALID_REASON, + PRELOAD_PERFORMANCE_METHOD, +} from '@iptvnator/shared/interfaces'; +import { + createCorrelationHarness, + OPERATION_ONE, + OPERATION_TWO, + PLAYLIST_ONE, + PLAYLIST_TWO, + type CorrelationEvent, + type CorrelationHarness, +} from './preload-performance-correlation.test-helpers'; + +describe('preload performance marker correlation sequences', () => { + let advance: CorrelationHarness['advance']; + let reset: CorrelationHarness['reset']; + + beforeEach(() => { + ({ advance, reset } = createCorrelationHarness()); + }); + + it('correlates the successful refresh persistence sequence and propagates its operation ID', () => { + const refreshStart = advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + const refreshSuccess = advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + const getStart = advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: null, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + const getSuccess = advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: null, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + const upsertStart = advance({ + ipcCallId: 3, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId: null, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + const upsertSuccess = advance({ + ipcCallId: 3, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId: null, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + + expect(refreshStart).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + invalidReason: null, + operationId: OPERATION_ONE, + playlistId: PLAYLIST_ONE, + }); + expect(refreshSuccess).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + operationId: OPERATION_ONE, + }); + expect(getStart.operationId).toBe(OPERATION_ONE); + expect(getSuccess.operationId).toBe(OPERATION_ONE); + expect(upsertStart.operationId).toBe(OPERATION_ONE); + expect(upsertSuccess).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.COMPLETE, + invalidReason: null, + operationId: OPERATION_ONE, + }); + }); + + it('fails closed for wrong-order and duplicate targeted database calls', () => { + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + + const wrongOrder = advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId: null, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + expect(wrongOrder).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER, + operationId: OPERATION_ONE, + }); + + reset(); + advance({ + ipcCallId: 3, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + advance({ + ipcCallId: 3, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + advance({ + ipcCallId: 4, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: null, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + const duplicate = advance({ + ipcCallId: 5, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: null, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + const originalCompletion = advance({ + ipcCallId: 4, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: null, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + + expect(duplicate.invalidReason).toBe( + PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER + ); + expect(originalCompletion).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER, + }); + }); + + it('invalidates concurrent refreshes for the same playlist', () => { + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + const concurrentStart = advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_TWO, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + const firstCompletion = advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + const concurrentCompletion = advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_TWO, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + const restart = advance({ + ipcCallId: 3, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: 'operation-3', + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + expect(concurrentStart).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: + PRELOAD_PERFORMANCE_INVALID_REASON.CONCURRENT_REFRESH, + operationId: OPERATION_TWO, + }); + expect(concurrentCompletion).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: + PRELOAD_PERFORMANCE_INVALID_REASON.CONCURRENT_REFRESH, + operationId: OPERATION_TWO, + }); + expect(restart.correlationState).toBe( + PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED + ); + expect(firstCompletion).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: + PRELOAD_PERFORMANCE_INVALID_REASON.CONCURRENT_REFRESH, + }); + }); + + it('keeps different playlists independent when one sequence invalidates', () => { + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_TWO, + phase: 'start', + playlistId: PLAYLIST_TWO, + }); + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_TWO, + phase: 'success', + playlistId: PLAYLIST_TWO, + }); + advance({ + ipcCallId: 3, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId: null, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + const playlistTwoGet = advance({ + ipcCallId: 4, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: null, + phase: 'start', + playlistId: PLAYLIST_TWO, + }); + + expect(playlistTwoGet).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + invalidReason: null, + operationId: OPERATION_TWO, + playlistId: PLAYLIST_TWO, + }); + }); + + it('allows a new refresh after the prior sequence completes', () => { + const events: CorrelationEvent[] = [ + { + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }, + { + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }, + { + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: null, + phase: 'start', + playlistId: PLAYLIST_ONE, + }, + { + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: null, + phase: 'success', + playlistId: PLAYLIST_ONE, + }, + { + ipcCallId: 3, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId: null, + phase: 'start', + playlistId: PLAYLIST_ONE, + }, + { + ipcCallId: 3, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId: null, + phase: 'success', + playlistId: PLAYLIST_ONE, + }, + ]; + events.forEach((event) => advance(event)); + + const restart = advance({ + ipcCallId: 4, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_TWO, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + expect(restart).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + invalidReason: null, + operationId: OPERATION_TWO, + }); + }); +}); diff --git a/apps/electron-backend/src/app/api/preload-performance-correlation.start.ts b/apps/electron-backend/src/app/api/preload-performance-correlation.start.ts new file mode 100644 index 000000000..271c02e15 --- /dev/null +++ b/apps/electron-backend/src/app/api/preload-performance-correlation.start.ts @@ -0,0 +1,251 @@ +import { + PRELOAD_PERFORMANCE_CORRELATION_STATE, + PRELOAD_PERFORMANCE_INVALID_REASON, + PRELOAD_PERFORMANCE_METHOD, +} from '@iptvnator/shared/interfaces'; +import { + PRELOAD_PERFORMANCE_SEQUENCE_STAGE, + createInvalidPreloadPerformanceCall, + createPreloadPerformanceMarkerMetadata, + invalidatePreloadPerformanceSequence, + type PreloadPerformanceCall, + type PreloadPerformanceCorrelationEvent, + type PreloadPerformanceMarkerMetadata, + type PreloadPerformanceSequence, +} from './preload-performance-correlation.model'; + +function startRefresh( + calls: Map, + sequences: Map, + event: PreloadPerformanceCorrelationEvent, + playlistId: string, + operationId: string +): PreloadPerformanceMarkerMetadata { + const sequence = sequences.get(playlistId); + if (sequence) { + const invalidSequence = invalidatePreloadPerformanceSequence( + calls, + sequences, + playlistId, + sequence.stage === PRELOAD_PERFORMANCE_SEQUENCE_STAGE.INVALID + ? (sequence.invalidReason ?? + PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER) + : PRELOAD_PERFORMANCE_INVALID_REASON.CONCURRENT_REFRESH + ); + const invalidReason = + invalidSequence?.invalidReason ?? + PRELOAD_PERFORMANCE_INVALID_REASON.CONCURRENT_REFRESH; + calls.set( + event.ipcCallId, + createInvalidPreloadPerformanceCall( + event, + playlistId, + operationId, + invalidReason, + operationId + ) + ); + return createPreloadPerformanceMarkerMetadata( + event, + playlistId, + operationId, + PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason + ); + } + + sequences.set(playlistId, { + activeCallId: event.ipcCallId, + invalidReason: null, + operationId, + stage: PRELOAD_PERFORMANCE_SEQUENCE_STAGE.REFRESH_STARTED, + }); + calls.set(event.ipcCallId, { + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + expectedOperationId: operationId, + invalidReason: null, + method: event.method, + operationId, + playlistId, + }); + return createPreloadPerformanceMarkerMetadata( + event, + playlistId, + operationId, + PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + null + ); +} + +function startDatabaseCall( + calls: Map, + sequences: Map, + event: PreloadPerformanceCorrelationEvent, + playlistId: string +): PreloadPerformanceMarkerMetadata { + const sequence = sequences.get(playlistId); + if (!sequence) { + calls.set(event.ipcCallId, { + correlationState: + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, + expectedOperationId: null, + invalidReason: null, + method: event.method, + operationId: null, + playlistId, + }); + return createPreloadPerformanceMarkerMetadata( + event, + playlistId, + null, + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, + null + ); + } + + if (sequence.stage === PRELOAD_PERFORMANCE_SEQUENCE_STAGE.INVALID) { + const invalidReason = + sequence.invalidReason ?? + PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER; + calls.set( + event.ipcCallId, + createInvalidPreloadPerformanceCall( + event, + playlistId, + sequence.operationId, + invalidReason, + null + ) + ); + return createPreloadPerformanceMarkerMetadata( + event, + playlistId, + sequence.operationId, + PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason + ); + } + + const isGet = + event.method === PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST; + const expectedStage = isGet + ? PRELOAD_PERFORMANCE_SEQUENCE_STAGE.REFRESH_SUCCEEDED + : PRELOAD_PERFORMANCE_SEQUENCE_STAGE.DB_GET_SUCCEEDED; + const startedStage = isGet + ? PRELOAD_PERFORMANCE_SEQUENCE_STAGE.DB_GET_STARTED + : PRELOAD_PERFORMANCE_SEQUENCE_STAGE.DB_UPSERT_STARTED; + if (sequence.stage !== expectedStage) { + const invalidSequence = invalidatePreloadPerformanceSequence( + calls, + sequences, + playlistId, + PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER + ); + const invalidReason = + invalidSequence?.invalidReason ?? + PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER; + calls.set( + event.ipcCallId, + createInvalidPreloadPerformanceCall( + event, + playlistId, + sequence.operationId, + invalidReason, + null + ) + ); + return createPreloadPerformanceMarkerMetadata( + event, + playlistId, + sequence.operationId, + PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason + ); + } + + sequences.set(playlistId, { + ...sequence, + activeCallId: event.ipcCallId, + stage: startedStage, + }); + calls.set(event.ipcCallId, { + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + expectedOperationId: null, + invalidReason: null, + method: event.method, + operationId: sequence.operationId, + playlistId, + }); + return createPreloadPerformanceMarkerMetadata( + event, + playlistId, + sequence.operationId, + PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + null + ); +} + +export function startPreloadPerformanceCall( + calls: Map, + sequences: Map, + event: PreloadPerformanceCorrelationEvent, + playlistId: string | null, + operationId: string | null +): PreloadPerformanceMarkerMetadata { + if ( + playlistId === null || + (event.method === PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST && + operationId === null) + ) { + if (playlistId !== null) { + invalidatePreloadPerformanceSequence( + calls, + sequences, + playlistId, + PRELOAD_PERFORMANCE_INVALID_REASON.MALFORMED_IDENTIFIER + ); + } + calls.set( + event.ipcCallId, + createInvalidPreloadPerformanceCall( + event, + playlistId, + null, + PRELOAD_PERFORMANCE_INVALID_REASON.MALFORMED_IDENTIFIER, + operationId + ) + ); + return createPreloadPerformanceMarkerMetadata( + event, + playlistId, + null, + PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + PRELOAD_PERFORMANCE_INVALID_REASON.MALFORMED_IDENTIFIER + ); + } + + const existingCall = calls.get(event.ipcCallId); + if (existingCall) { + const sequence = + existingCall.playlistId === null + ? undefined + : invalidatePreloadPerformanceSequence( + calls, + sequences, + existingCall.playlistId, + PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER + ); + return createPreloadPerformanceMarkerMetadata( + event, + playlistId, + sequence?.operationId ?? null, + PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + sequence?.invalidReason ?? + PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER + ); + } + + return event.method === PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST + ? startRefresh(calls, sequences, event, playlistId, operationId) + : startDatabaseCall(calls, sequences, event, playlistId); +} diff --git a/apps/electron-backend/src/app/api/preload-performance-correlation.test-helpers.ts b/apps/electron-backend/src/app/api/preload-performance-correlation.test-helpers.ts new file mode 100644 index 000000000..bf4d29321 --- /dev/null +++ b/apps/electron-backend/src/app/api/preload-performance-correlation.test-helpers.ts @@ -0,0 +1,29 @@ +import { + advancePreloadPerformanceCorrelation, + createPreloadPerformanceCorrelationState, + type PreloadPerformanceCorrelationEvent, +} from './preload-performance-correlation'; + +export const PLAYLIST_ONE = 'playlist-1'; +export const PLAYLIST_TWO = 'playlist-2'; +export const OPERATION_ONE = 'operation-1'; +export const OPERATION_TWO = 'operation-2'; + +export type CorrelationEvent = PreloadPerformanceCorrelationEvent; + +export function createCorrelationHarness() { + let state = createPreloadPerformanceCorrelationState(); + + return { + advance: (event: PreloadPerformanceCorrelationEvent) => { + const result = advancePreloadPerformanceCorrelation(state, event); + state = result.state; + return result.marker; + }, + reset: () => { + state = createPreloadPerformanceCorrelationState(); + }, + }; +} + +export type CorrelationHarness = ReturnType; diff --git a/apps/electron-backend/src/app/api/preload-performance-correlation.ts b/apps/electron-backend/src/app/api/preload-performance-correlation.ts new file mode 100644 index 000000000..05a7de9e4 --- /dev/null +++ b/apps/electron-backend/src/app/api/preload-performance-correlation.ts @@ -0,0 +1,60 @@ +import { PRELOAD_PERFORMANCE_METHOD } from '@iptvnator/shared/interfaces'; +import { completePreloadPerformanceCall } from './preload-performance-correlation.complete'; +import { + normalizePreloadPerformanceIdentifier, + type PreloadPerformanceCorrelationEvent, + type PreloadPerformanceCorrelationResult, + type PreloadPerformanceCorrelationState, +} from './preload-performance-correlation.model'; +import { startPreloadPerformanceCall } from './preload-performance-correlation.start'; + +export type { + PreloadPerformanceCorrelationEvent, + PreloadPerformanceCorrelationResult, + PreloadPerformanceCorrelationState, +} from './preload-performance-correlation.model'; +export { normalizePreloadPerformanceIdentifier } from './preload-performance-correlation.model'; + +export function createPreloadPerformanceCorrelationState(): PreloadPerformanceCorrelationState { + return { + calls: new Map(), + sequences: new Map(), + }; +} + +export function advancePreloadPerformanceCorrelation( + state: PreloadPerformanceCorrelationState, + event: PreloadPerformanceCorrelationEvent +): PreloadPerformanceCorrelationResult { + const calls = new Map(state.calls); + const sequences = new Map(state.sequences); + const playlistId = normalizePreloadPerformanceIdentifier(event.playlistId); + const operationId = + event.method === PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST + ? normalizePreloadPerformanceIdentifier(event.operationId) + : null; + const marker = + event.phase === 'start' + ? startPreloadPerformanceCall( + calls, + sequences, + event, + playlistId, + operationId + ) + : completePreloadPerformanceCall( + calls, + sequences, + event, + playlistId, + operationId + ); + + return { + marker, + state: { + calls, + sequences, + }, + }; +} diff --git a/apps/electron-backend/src/app/api/preload-performance-correlation.validation.spec.ts b/apps/electron-backend/src/app/api/preload-performance-correlation.validation.spec.ts new file mode 100644 index 000000000..9252bf378 --- /dev/null +++ b/apps/electron-backend/src/app/api/preload-performance-correlation.validation.spec.ts @@ -0,0 +1,117 @@ +import { + PRELOAD_PERFORMANCE_CORRELATION_STATE, + PRELOAD_PERFORMANCE_INVALID_REASON, + PRELOAD_PERFORMANCE_METHOD, +} from '@iptvnator/shared/interfaces'; +import { + createCorrelationHarness, + OPERATION_ONE, + OPERATION_TWO, + PLAYLIST_ONE, + type CorrelationHarness, +} from './preload-performance-correlation.test-helpers'; + +describe('preload performance marker correlation validation', () => { + let advance: CorrelationHarness['advance']; + + beforeEach(() => { + ({ advance } = createCorrelationHarness()); + }); + + it('uses null identifiers and a fixed reason for malformed inputs', () => { + const malformedPlaylist = advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: 'https://secret.example/private.m3u', + }); + const malformedOperation = advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: { secret: OPERATION_TWO }, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + const malformedDbId = advance({ + ipcCallId: 3, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId: null, + phase: 'start', + playlistId: undefined, + }); + const malformedPlaylistCompletion = advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: 'https://secret.example/private.m3u', + }); + + expect(malformedPlaylist).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: + PRELOAD_PERFORMANCE_INVALID_REASON.MALFORMED_IDENTIFIER, + operationId: null, + playlistId: null, + }); + expect(malformedOperation).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: + PRELOAD_PERFORMANCE_INVALID_REASON.MALFORMED_IDENTIFIER, + operationId: null, + playlistId: PLAYLIST_ONE, + }); + expect(malformedDbId).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: + PRELOAD_PERFORMANCE_INVALID_REASON.MALFORMED_IDENTIFIER, + operationId: null, + playlistId: null, + }); + expect(malformedPlaylistCompletion).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: + PRELOAD_PERFORMANCE_INVALID_REASON.MALFORMED_IDENTIFIER, + operationId: null, + playlistId: null, + }); + }); + + it('keeps targeted database calls outside a sequence visible and uncorrelated', () => { + const getStart = advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: null, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + const getSuccess = advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: null, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + + expect(getStart).toMatchObject({ + correlationState: + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, + invalidReason: null, + operationId: null, + }); + expect(getSuccess).toMatchObject({ + correlationState: + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, + invalidReason: null, + operationId: null, + }); + }); +}); diff --git a/apps/electron-backend/src/app/services/debug-trace.ts b/apps/electron-backend/src/app/services/debug-trace.ts index f134dc7f3..6b926a6c3 100644 --- a/apps/electron-backend/src/app/services/debug-trace.ts +++ b/apps/electron-backend/src/app/services/debug-trace.ts @@ -52,6 +52,10 @@ export function isRendererApiTraceEnabled(): boolean { return isStartupTraceEnabled() || readFlag('IPTVNATOR_TRACE_IPC'); } +export function isPerformanceCaptureEnabled(): boolean { + return readFlag('IPTVNATOR_PERF_CAPTURE'); +} + export function isDbTraceEnabled(): boolean { return isStartupTraceEnabled() || readFlag('IPTVNATOR_TRACE_DB'); } diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index f4d51e0bc..c3aa8bf8c 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -23,6 +23,7 @@ export * from './lib/playback-position.interface'; export * from './lib/playlist-auto-update.interface'; export * from './lib/playlist-backup.interface'; export * from './lib/playlist-meta.type'; +export * from './lib/preload-performance-marker.interface'; export * from './lib/playlist-recently-viewed.interface'; export * from './lib/playlist-recently-viewed.utils'; export * from './lib/playlist-refresh.interface'; diff --git a/libs/shared/interfaces/src/lib/preload-performance-marker.interface.ts b/libs/shared/interfaces/src/lib/preload-performance-marker.interface.ts new file mode 100644 index 000000000..268f848ae --- /dev/null +++ b/libs/shared/interfaces/src/lib/preload-performance-marker.interface.ts @@ -0,0 +1,52 @@ +export const PRELOAD_PERFORMANCE_MARKER_CHANNEL = + 'IPTVNATOR_PERF_CAPTURE_MARKER'; + +export const PRELOAD_PERFORMANCE_METHOD = { + DB_GET_APP_PLAYLIST: 'dbGetAppPlaylist', + DB_UPSERT_APP_PLAYLIST: 'dbUpsertAppPlaylist', + REFRESH_PLAYLIST: 'refreshPlaylist', +} as const; + +export type PreloadPerformanceMethod = + (typeof PRELOAD_PERFORMANCE_METHOD)[keyof typeof PRELOAD_PERFORMANCE_METHOD]; + +export const PRELOAD_PERFORMANCE_PHASE = { + ERROR: 'error', + START: 'start', + SUCCESS: 'success', +} as const; + +export type PreloadPerformancePhase = + (typeof PRELOAD_PERFORMANCE_PHASE)[keyof typeof PRELOAD_PERFORMANCE_PHASE]; + +export const PRELOAD_PERFORMANCE_CORRELATION_STATE = { + COMPLETE: 'complete', + CORRELATED: 'correlated', + INVALID: 'invalid', + UNCORRELATED: 'uncorrelated', +} as const; + +export type PreloadPerformanceCorrelationStateName = + (typeof PRELOAD_PERFORMANCE_CORRELATION_STATE)[keyof typeof PRELOAD_PERFORMANCE_CORRELATION_STATE]; + +export const PRELOAD_PERFORMANCE_INVALID_REASON = { + CONCURRENT_REFRESH: 'concurrent-refresh', + IPC_ERROR: 'ipc-error', + MALFORMED_IDENTIFIER: 'malformed-identifier', + OUT_OF_ORDER: 'out-of-order', + REFRESH_CANCELLED: 'refresh-cancelled', +} as const; + +export type PreloadPerformanceInvalidReason = + (typeof PRELOAD_PERFORMANCE_INVALID_REASON)[keyof typeof PRELOAD_PERFORMANCE_INVALID_REASON]; + +export interface PreloadPerformanceMarker { + method: PreloadPerformanceMethod; + phase: PreloadPerformancePhase; + sourceEpochMs: number; + ipcCallId: number; + playlistId: string | null; + operationId: string | null; + correlationState: PreloadPerformanceCorrelationStateName; + invalidReason: PreloadPerformanceInvalidReason | null; +} From f9e71a6d8d836b60f845c837a6f5cbb3ffe309e3 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 26 Jul 2026 19:32:23 +0200 Subject: [PATCH 4/9] fix(electron): isolate refresh performance correlation --- .../m3u-refresh-cancellation.benchmark.ts | 1 + .../performance-build-config.spec.ts | 12 + .../main.preload.performance.contract.spec.ts | 45 +++- .../main.preload.performance.gates.spec.ts | 98 ++++++++ .../main.preload.performance.identity.spec.ts | 79 +++++++ .../src/app/api/main.preload.ts | 4 +- .../app/api/preload-performance-capture.ts | 14 +- ...reload-performance-correlation.complete.ts | 98 ++++---- ...performance-correlation.completion.spec.ts | 209 +----------------- ...d-performance-correlation.identity.spec.ts | 183 +++++++++++++++ .../preload-performance-correlation.model.ts | 7 +- ...d-performance-correlation.sequence.spec.ts | 28 +-- .../preload-performance-correlation.start.ts | 118 +++++----- .../api/preload-performance-correlation.ts | 8 +- libs/m3u-state/src/lib/actions.ts | 9 +- libs/m3u-state/src/lib/effects.ts | 46 ++-- .../src/lib/playlist-update.effect-handler.ts | 29 +++ .../src/lib/playlist-update.effect.spec.ts | 30 +++ .../playlist-refresh-action.service.spec.ts | 1 + .../lib/playlist-refresh-action.service.ts | 8 +- .../recent-playlists.component.spec.ts | 10 + .../recent-playlists.component.ts | 1 + .../src/lib/playlists.service.spec.ts | 31 +++ libs/services/src/lib/playlists.service.ts | 49 ++-- .../src/lib/electron-api.interface.ts | 12 +- 25 files changed, 728 insertions(+), 402 deletions(-) create mode 100644 apps/electron-backend/src/app/api/main.preload.performance.identity.spec.ts create mode 100644 apps/electron-backend/src/app/api/preload-performance-correlation.identity.spec.ts create mode 100644 libs/m3u-state/src/lib/playlist-update.effect-handler.ts create mode 100644 libs/m3u-state/src/lib/playlist-update.effect.spec.ts diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts index fcfd83d1f..ce4cd5e0b 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts @@ -166,6 +166,7 @@ async function runIteration( ], env: { IPTVNATOR_DB_WORKER_BATCH_DELAY_MS: '0', + IPTVNATOR_PERF_CAPTURE: '1', IPTVNATOR_PERF_WORKER_PROFILING: '1', IPTVNATOR_TRACE_RENDERER_CONSOLE: process.env['IPTVNATOR_TRACE_RENDERER_CONSOLE'] ?? '0', diff --git a/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts b/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts index 33f81351b..94a64095a 100644 --- a/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts +++ b/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts @@ -224,3 +224,15 @@ test('the cancellation benchmark command is pinned to its Playwright test file', 'pnpm exec playwright test --config=playwright.performance.config.ts src/m3u-refresh-cancellation.performance.ts' ); }); + +test('the cancellation benchmark enables preload performance capture', () => { + const source = readFileSync( + join( + workspaceRoot, + 'apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts' + ), + 'utf8' + ); + + assert.match(source, /IPTVNATOR_PERF_CAPTURE:\s*'1'/); +}); diff --git a/apps/electron-backend/src/app/api/main.preload.performance.contract.spec.ts b/apps/electron-backend/src/app/api/main.preload.performance.contract.spec.ts index f2956075d..e4dc78a44 100644 --- a/apps/electron-backend/src/app/api/main.preload.performance.contract.spec.ts +++ b/apps/electron-backend/src/app/api/main.preload.performance.contract.spec.ts @@ -77,12 +77,12 @@ describe('main preload performance marker contract', () => { await expect(api.refreshPlaylist(payload)).resolves.toBe( refreshedPlaylist ); - await expect(api.dbGetAppPlaylist(payload.playlistId)).resolves.toBe( - storedPlaylist - ); - await expect(api.dbUpsertAppPlaylist(upsertPlaylist)).resolves.toBe( - upsertResult - ); + await expect( + api.dbGetAppPlaylist(payload.playlistId, payload.operationId) + ).resolves.toBe(storedPlaylist); + await expect( + api.dbUpsertAppPlaylist(upsertPlaylist, payload.operationId) + ).resolves.toBe(upsertResult); expect(harness.ipcRenderer.invoke.mock.calls).toEqual([ ['PLAYLIST:REFRESH', payload], @@ -289,12 +289,15 @@ describe('main preload performance marker contract', () => { ); }); - it('keeps results unchanged when marker delivery throws', async () => { + it('poisons correlation after the first marker delivery failure', async () => { const payload = createRefreshPayload(); const result = createPlaylist(payload.playlistId); await loadPerformancePreload(); - harness.ipcRenderer.invoke.mockResolvedValueOnce(result); - harness.ipcRenderer.send.mockImplementation((channel: string) => { + harness.ipcRenderer.invoke + .mockResolvedValueOnce(result) + .mockResolvedValueOnce(result) + .mockResolvedValueOnce({ success: true }); + harness.ipcRenderer.send.mockImplementationOnce((channel: string) => { if (channel === PRELOAD_PERFORMANCE_MARKER_CHANNEL) { throw new Error('marker-send-failed'); } @@ -303,11 +306,33 @@ describe('main preload performance marker contract', () => { await expect(harness.getApi().refreshPlaylist(payload)).resolves.toBe( result ); + await expect( + harness + .getApi() + .dbGetAppPlaylist(payload.playlistId, payload.operationId) + ).resolves.toBe(result); + await expect( + harness.getApi().dbUpsertAppPlaylist(result, payload.operationId) + ).resolves.toEqual({ success: true }); expect( harness.ipcRenderer.send.mock.calls.filter( ([channel]) => channel === PRELOAD_PERFORMANCE_MARKER_CHANNEL ) - ).toHaveLength(2); + ).toHaveLength(1); + expect(harness.getMarkers()).toHaveLength(1); + expect(harness.getMarkers()[0]).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + phase: 'start', + }); + expect( + harness + .getMarkers() + .some( + ({ correlationState }) => + correlationState === + PRELOAD_PERFORMANCE_CORRELATION_STATE.COMPLETE + ) + ).toBe(false); }); }); diff --git a/apps/electron-backend/src/app/api/main.preload.performance.gates.spec.ts b/apps/electron-backend/src/app/api/main.preload.performance.gates.spec.ts index 36b8a2b1b..518f9376d 100644 --- a/apps/electron-backend/src/app/api/main.preload.performance.gates.spec.ts +++ b/apps/electron-backend/src/app/api/main.preload.performance.gates.spec.ts @@ -3,7 +3,9 @@ import { PRELOAD_PERFORMANCE_MARKER_CHANNEL, PRELOAD_PERFORMANCE_METHOD, type Settings, + Theme, } from '@iptvnator/shared/interfaces'; +import { redactSensitiveData } from '@iptvnator/shared/logging'; import { DEBUG_TRACE_EVENT_CHANNEL } from '../services/debug-trace'; import { createPreloadPerformanceHarness, @@ -20,6 +22,7 @@ describe('main preload performance marker gates', () => { afterEach(() => { harness.cleanup(); + jest.restoreAllMocks(); }); it.each([undefined, '', '0', 'false', 'no', 'off'])( @@ -146,4 +149,99 @@ describe('main preload performance marker gates', () => { ); expect(harness.getMarkers()).toEqual([]); }); + + it('preserves the full legacy trace payload for non-target success', async () => { + const settings = { + password: 'sentinel-argument-secret', + theme: Theme.DarkTheme, + } as Partial; + const result = { + password: 'sentinel-result-secret', + success: true, + }; + await harness.load({ + [PRELOAD_ENVIRONMENT.TRACE_IPC]: '1', + }); + jest.spyOn(globalThis.performance, 'now') + .mockReturnValueOnce(10) + .mockReturnValueOnce(12.34); + harness.ipcRenderer.invoke.mockResolvedValueOnce(result); + + await expect(harness.getApi().updateSettings(settings)).resolves.toBe( + result + ); + + const traces = harness.getSentPayloads>( + DEBUG_TRACE_EVENT_CHANNEL + ); + expect(traces).toEqual([ + { + args: { + items: [ + { + password: 'sentinel-argument-secret', + theme: Theme.DarkTheme, + }, + ], + length: 1, + type: 'array', + }, + method: 'updateSettings', + phase: 'start', + }, + { + durationMs: 2.3, + method: 'updateSettings', + phase: 'success', + result, + }, + ]); + const redacted = JSON.stringify(redactSensitiveData(traces)); + expect(redacted).not.toContain('sentinel-argument-secret'); + expect(redacted).not.toContain('sentinel-result-secret'); + expect(harness.getMarkers()).toEqual([]); + }); + + it('preserves the full legacy trace payload for non-target errors', async () => { + const rejection = new Error('token=sentinel-error-secret'); + await harness.load({ + [PRELOAD_ENVIRONMENT.TRACE_IPC]: '1', + }); + jest.spyOn(globalThis.performance, 'now') + .mockReturnValueOnce(20) + .mockReturnValueOnce(23.26); + harness.ipcRenderer.invoke.mockRejectedValueOnce(rejection); + + await expect( + harness.getApi().updateSettings({ theme: Theme.LightTheme }) + ).rejects.toBe(rejection); + + const traces = harness.getSentPayloads>( + DEBUG_TRACE_EVENT_CHANNEL + ); + expect(traces).toEqual([ + { + args: { + items: [{ theme: Theme.LightTheme }], + length: 1, + type: 'array', + }, + method: 'updateSettings', + phase: 'start', + }, + { + durationMs: 3.3, + error: { + message: rejection.message, + name: 'Error', + }, + method: 'updateSettings', + phase: 'error', + }, + ]); + expect(JSON.stringify(redactSensitiveData(traces))).not.toContain( + 'sentinel-error-secret' + ); + expect(harness.getMarkers()).toEqual([]); + }); }); diff --git a/apps/electron-backend/src/app/api/main.preload.performance.identity.spec.ts b/apps/electron-backend/src/app/api/main.preload.performance.identity.spec.ts new file mode 100644 index 000000000..ae6306c18 --- /dev/null +++ b/apps/electron-backend/src/app/api/main.preload.performance.identity.spec.ts @@ -0,0 +1,79 @@ +import { + PRELOAD_PERFORMANCE_CORRELATION_STATE, + PRELOAD_PERFORMANCE_METHOD, +} from '@iptvnator/shared/interfaces'; +import { + createPlaylist, + createPreloadPerformanceHarness, + createRefreshPayload, + PRELOAD_ENVIRONMENT, + type PreloadPerformanceHarness, +} from './main.preload.performance.test-helpers'; + +describe('main preload performance marker identities', () => { + let harness: PreloadPerformanceHarness; + + beforeEach(async () => { + harness = createPreloadPerformanceHarness(); + await harness.load({ + [PRELOAD_ENVIRONMENT.PERF_CAPTURE]: '1', + }); + }); + + afterEach(() => { + harness.cleanup(); + }); + + it('completes only for database calls carrying the refresh operation ID', async () => { + const payload = createRefreshPayload(); + const playlist = createPlaylist(payload.playlistId); + harness.ipcRenderer.invoke.mockResolvedValue(playlist); + const api = harness.getApi(); + + await api.refreshPlaylist(payload); + await api.dbGetAppPlaylist(payload.playlistId); + await api.dbUpsertAppPlaylist(playlist); + await api.dbGetAppPlaylist(payload.playlistId, 'other-operation'); + await api.dbUpsertAppPlaylist(playlist, 'other-operation'); + + expect( + harness + .getMarkers() + .slice(2) + .every( + ({ correlationState }) => + correlationState === + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED + ) + ).toBe(true); + expect( + harness + .getMarkers() + .some( + ({ correlationState }) => + correlationState === + PRELOAD_PERFORMANCE_CORRELATION_STATE.COMPLETE + ) + ).toBe(false); + + await api.dbGetAppPlaylist(payload.playlistId, payload.operationId); + await api.dbUpsertAppPlaylist(playlist, payload.operationId); + + const markers = harness.getMarkers(); + expect(markers.at(-1)).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.COMPLETE, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId: payload.operationId, + playlistId: payload.playlistId, + }); + expect(harness.ipcRenderer.invoke.mock.calls).toEqual([ + ['PLAYLIST:REFRESH', payload], + ['DB_GET_APP_PLAYLIST', payload.playlistId], + ['DB_UPSERT_APP_PLAYLIST', playlist], + ['DB_GET_APP_PLAYLIST', payload.playlistId], + ['DB_UPSERT_APP_PLAYLIST', playlist], + ['DB_GET_APP_PLAYLIST', payload.playlistId], + ['DB_UPSERT_APP_PLAYLIST', playlist], + ]); + }); +}); diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index e93d611dd..6f39995da 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -618,14 +618,14 @@ const electronApi: ElectronBridgeApi = { ipcRenderer.invoke('DB_CREATE_PLAYLIST', playlist), dbGetPlaylist: (playlistId: string) => ipcRenderer.invoke('DB_GET_PLAYLIST', playlistId), - dbUpsertAppPlaylist: (playlist: Playlist) => + dbUpsertAppPlaylist: (playlist: Playlist, _operationId?: string) => ipcRenderer.invoke('DB_UPSERT_APP_PLAYLIST', playlist), dbUpsertAppPlaylists: (playlists: Playlist[]) => ipcRenderer.invoke('DB_UPSERT_APP_PLAYLISTS', playlists), dbGetAppPlaylists: () => ipcRenderer.invoke('DB_GET_APP_PLAYLISTS'), dbGetAppPlaylistMetas: () => ipcRenderer.invoke('DB_GET_APP_PLAYLIST_METAS'), - dbGetAppPlaylist: (playlistId: string) => + dbGetAppPlaylist: (playlistId: string, _operationId?: string) => ipcRenderer.invoke('DB_GET_APP_PLAYLIST', playlistId), dbGetAppPlaylistFavoriteChannels: (playlistId: string) => ipcRenderer.invoke('DB_GET_APP_PLAYLIST_FAVORITE_CHANNELS', playlistId), diff --git a/apps/electron-backend/src/app/api/preload-performance-capture.ts b/apps/electron-backend/src/app/api/preload-performance-capture.ts index c6543d3d3..62ae3e25d 100644 --- a/apps/electron-backend/src/app/api/preload-performance-capture.ts +++ b/apps/electron-backend/src/app/api/preload-performance-capture.ts @@ -96,13 +96,13 @@ function extractIdentifiers( if (method === PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST) { return { - operationId: null, + operationId: normalizePreloadPerformanceIdentifier(args[1]), playlistId: normalizePreloadPerformanceIdentifier(args[0]), }; } return { - operationId: null, + operationId: normalizePreloadPerformanceIdentifier(args[1]), playlistId: normalizePreloadPerformanceIdentifier( readProperty(args[0], '_id') ), @@ -115,6 +115,7 @@ export function createPreloadPerformanceCapture( ): PreloadPerformanceCapture { let correlationState: PreloadPerformanceCorrelationState = createPreloadPerformanceCorrelationState(); + let markerDeliveryFailed = false; let lastSourceEpochMs = 0; let nextIpcCallId = 1; @@ -123,6 +124,10 @@ export function createPreloadPerformanceCapture( phase: PreloadPerformancePhase, refreshCancelled = false ): void { + if (markerDeliveryFailed) { + return; + } + try { const transition = advancePreloadPerformanceCorrelation( correlationState, @@ -132,17 +137,18 @@ export function createPreloadPerformanceCapture( refreshCancelled, } ); - correlationState = transition.state; const sourceEpochMs = Math.max( readHighResolutionEpochMs(), lastSourceEpochMs ); - lastSourceEpochMs = sourceEpochMs; sendMarker(PRELOAD_PERFORMANCE_MARKER_CHANNEL, { ...transition.marker, sourceEpochMs, }); + correlationState = transition.state; + lastSourceEpochMs = sourceEpochMs; } catch { + markerDeliveryFailed = true; // Performance instrumentation must never affect the bridge call. } } diff --git a/apps/electron-backend/src/app/api/preload-performance-correlation.complete.ts b/apps/electron-backend/src/app/api/preload-performance-correlation.complete.ts index 26e79ef46..b27e327b3 100644 --- a/apps/electron-backend/src/app/api/preload-performance-correlation.complete.ts +++ b/apps/electron-backend/src/app/api/preload-performance-correlation.complete.ts @@ -14,12 +14,25 @@ import { type PreloadPerformanceSequence, } from './preload-performance-correlation.model'; +function eventForRememberedCall( + event: PreloadPerformanceCorrelationEvent, + call: PreloadPerformanceCall +): PreloadPerformanceCorrelationEvent { + return { + ...event, + method: call.method, + operationId: call.operationId, + playlistId: call.playlistId, + }; +} + function invalidCompletion( calls: Map, sequences: Map, event: PreloadPerformanceCorrelationEvent, call: PreloadPerformanceCall ): PreloadPerformanceMarkerMetadata { + const rememberedEvent = eventForRememberedCall(event, call); const invalidSequence = call.playlistId === null ? undefined @@ -36,11 +49,12 @@ function invalidCompletion( call.playlistId ); return createPreloadPerformanceMarkerMetadata( - event, + rememberedEvent, call.playlistId, invalidSequence?.operationId ?? call.operationId, PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, invalidSequence?.invalidReason ?? + call.invalidReason ?? PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER ); } @@ -49,26 +63,28 @@ function completeCorrelatedCall( calls: Map, sequences: Map, event: PreloadPerformanceCorrelationEvent, - call: PreloadPerformanceCall & { playlistId: string } + call: PreloadPerformanceCall & { + operationId: string; + playlistId: string; + } ): PreloadPerformanceMarkerMetadata { const sequence = sequences.get(call.playlistId); const expectedStage = - event.method === PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST + call.method === PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST ? PRELOAD_PERFORMANCE_SEQUENCE_STAGE.REFRESH_STARTED - : event.method === PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST + : call.method === PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST ? PRELOAD_PERFORMANCE_SEQUENCE_STAGE.DB_GET_STARTED : PRELOAD_PERFORMANCE_SEQUENCE_STAGE.DB_UPSERT_STARTED; if ( !sequence || - sequence.stage !== expectedStage || - sequence.activeCallId !== event.ipcCallId || - sequence.operationId !== call.operationId + sequence.operationId !== call.operationId || + sequence.stage !== expectedStage ) { return invalidCompletion(calls, sequences, event, call); } if (event.phase === 'error') { - const invalidSequence = invalidatePreloadPerformanceSequence( + invalidatePreloadPerformanceSequence( calls, sequences, call.playlistId, @@ -83,54 +99,51 @@ function completeCorrelatedCall( return createPreloadPerformanceMarkerMetadata( event, call.playlistId, - sequence.operationId, + call.operationId, PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, - invalidSequence?.invalidReason ?? - PRELOAD_PERFORMANCE_INVALID_REASON.IPC_ERROR + PRELOAD_PERFORMANCE_INVALID_REASON.IPC_ERROR ); } if ( - event.method === PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST && + call.method === PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST && event.refreshCancelled === true ) { - sequences.delete(call.playlistId); calls.delete(event.ipcCallId); + sequences.delete(call.playlistId); return createPreloadPerformanceMarkerMetadata( event, call.playlistId, - sequence.operationId, + call.operationId, PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, PRELOAD_PERFORMANCE_INVALID_REASON.REFRESH_CANCELLED ); } calls.delete(event.ipcCallId); - if (event.method === PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST) { + if (call.method === PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST) { sequences.set(call.playlistId, { ...sequence, - activeCallId: null, stage: PRELOAD_PERFORMANCE_SEQUENCE_STAGE.REFRESH_SUCCEEDED, }); return createPreloadPerformanceMarkerMetadata( event, call.playlistId, - sequence.operationId, + call.operationId, PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, null ); } - if (event.method === PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST) { + if (call.method === PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST) { sequences.set(call.playlistId, { ...sequence, - activeCallId: null, stage: PRELOAD_PERFORMANCE_SEQUENCE_STAGE.DB_GET_SUCCEEDED, }); return createPreloadPerformanceMarkerMetadata( event, call.playlistId, - sequence.operationId, + call.operationId, PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, null ); @@ -140,7 +153,7 @@ function completeCorrelatedCall( return createPreloadPerformanceMarkerMetadata( event, call.playlistId, - sequence.operationId, + call.operationId, PRELOAD_PERFORMANCE_CORRELATION_STATE.COMPLETE, null ); @@ -155,50 +168,33 @@ export function completePreloadPerformanceCall( ): PreloadPerformanceMarkerMetadata { const call = calls.get(event.ipcCallId); if (!call) { - const sequence = playlistId ? sequences.get(playlistId) : undefined; - const invalidSequence = playlistId - ? invalidatePreloadPerformanceSequence( - calls, - sequences, - playlistId, - PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER - ) - : undefined; - const invalidReason = - invalidSequence?.invalidReason ?? - PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER; - closeInvalidPreloadPerformanceSequenceWhenSettled( - calls, - sequences, - playlistId - ); return createPreloadPerformanceMarkerMetadata( event, playlistId, - invalidSequence?.operationId ?? sequence?.operationId ?? null, + operationId, PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, - invalidReason + PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER ); } const identityMatches = call.method === event.method && - call.playlistId === playlistId && - (event.method !== PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST || - call.expectedOperationId === operationId); + call.operationId === operationId && + call.playlistId === playlistId; if (!identityMatches) { return invalidCompletion(calls, sequences, event, call); } + const rememberedEvent = eventForRememberedCall(event, call); if ( call.correlationState === PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED ) { calls.delete(event.ipcCallId); return createPreloadPerformanceMarkerMetadata( - event, + rememberedEvent, call.playlistId, - null, + call.operationId, PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, null ); @@ -214,7 +210,7 @@ export function completePreloadPerformanceCall( call.playlistId ); return createPreloadPerformanceMarkerMetadata( - event, + rememberedEvent, call.playlistId, call.operationId, PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, @@ -223,13 +219,13 @@ export function completePreloadPerformanceCall( ); } - if (call.playlistId === null) { - return invalidCompletion(calls, sequences, event, call); - } return completeCorrelatedCall( calls, sequences, - event, - call as PreloadPerformanceCall & { playlistId: string } + rememberedEvent, + call as PreloadPerformanceCall & { + operationId: string; + playlistId: string; + } ); } diff --git a/apps/electron-backend/src/app/api/preload-performance-correlation.completion.spec.ts b/apps/electron-backend/src/app/api/preload-performance-correlation.completion.spec.ts index 97f13f081..6cde5c2cc 100644 --- a/apps/electron-backend/src/app/api/preload-performance-correlation.completion.spec.ts +++ b/apps/electron-backend/src/app/api/preload-performance-correlation.completion.spec.ts @@ -8,210 +8,14 @@ import { OPERATION_ONE, OPERATION_TWO, PLAYLIST_ONE, - PLAYLIST_TWO, type CorrelationHarness, } from './preload-performance-correlation.test-helpers'; describe('preload performance marker correlation completions', () => { let advance: CorrelationHarness['advance']; - let reset: CorrelationHarness['reset']; beforeEach(() => { - ({ advance, reset } = createCorrelationHarness()); - }); - - it.each(['success', 'error'] as const)( - 'rejects a %s completion with the wrong call ID', - (phase) => { - advance({ - ipcCallId: 1, - method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, - operationId: OPERATION_ONE, - phase: 'start', - playlistId: PLAYLIST_ONE, - }); - - const mismatchedCompletion = advance({ - ipcCallId: 99, - method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, - operationId: OPERATION_ONE, - phase, - playlistId: PLAYLIST_ONE, - }); - const originalCompletion = advance({ - ipcCallId: 1, - method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, - operationId: OPERATION_ONE, - phase: 'success', - playlistId: PLAYLIST_ONE, - }); - - expect(mismatchedCompletion).toMatchObject({ - correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, - invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER, - }); - expect(originalCompletion).toMatchObject({ - correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, - invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER, - }); - } - ); - - it('rejects a refresh completion whose playlist or operation ID changed', () => { - advance({ - ipcCallId: 1, - method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, - operationId: OPERATION_ONE, - phase: 'start', - playlistId: PLAYLIST_ONE, - }); - - const changedOperation = advance({ - ipcCallId: 1, - method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, - operationId: OPERATION_TWO, - phase: 'success', - playlistId: PLAYLIST_ONE, - }); - - expect(changedOperation).toMatchObject({ - correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, - invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER, - }); - - reset(); - advance({ - ipcCallId: 2, - method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, - operationId: OPERATION_ONE, - phase: 'start', - playlistId: PLAYLIST_ONE, - }); - - const changedPlaylist = advance({ - ipcCallId: 2, - method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, - operationId: OPERATION_ONE, - phase: 'success', - playlistId: PLAYLIST_TWO, - }); - - expect(changedPlaylist).toMatchObject({ - correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, - invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER, - }); - }); - - it('rejects a completion whose method differs from the started call', () => { - advance({ - ipcCallId: 1, - method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, - operationId: OPERATION_ONE, - phase: 'start', - playlistId: PLAYLIST_ONE, - }); - - const wrongMethod = advance({ - ipcCallId: 1, - method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, - operationId: null, - phase: 'success', - playlistId: PLAYLIST_ONE, - }); - const restart = advance({ - ipcCallId: 2, - method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, - operationId: OPERATION_TWO, - phase: 'start', - playlistId: PLAYLIST_ONE, - }); - - expect(wrongMethod).toMatchObject({ - correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, - invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER, - }); - expect(restart.correlationState).toBe( - PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED - ); - }); - - it('rejects a duplicate completion instead of advancing the chain twice', () => { - const refresh = { - ipcCallId: 1, - method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, - operationId: OPERATION_ONE, - playlistId: PLAYLIST_ONE, - } as const; - advance({ ...refresh, phase: 'start' }); - advance({ ...refresh, phase: 'success' }); - - const duplicate = advance({ ...refresh, phase: 'success' }); - const laterGet = advance({ - ipcCallId: 2, - method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, - operationId: null, - phase: 'start', - playlistId: PLAYLIST_ONE, - }); - - expect(duplicate).toMatchObject({ - correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, - invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER, - }); - expect(laterGet).toMatchObject({ - correlationState: - PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, - invalidReason: null, - }); - }); - - it('rejects a database completion for a different playlist', () => { - advance({ - ipcCallId: 1, - method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, - operationId: OPERATION_ONE, - phase: 'start', - playlistId: PLAYLIST_ONE, - }); - advance({ - ipcCallId: 1, - method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, - operationId: OPERATION_ONE, - phase: 'success', - playlistId: PLAYLIST_ONE, - }); - advance({ - ipcCallId: 2, - method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, - operationId: null, - phase: 'start', - playlistId: PLAYLIST_ONE, - }); - - const changedPlaylist = advance({ - ipcCallId: 2, - method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, - operationId: null, - phase: 'success', - playlistId: PLAYLIST_TWO, - }); - const laterUpsert = advance({ - ipcCallId: 3, - method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, - operationId: null, - phase: 'start', - playlistId: PLAYLIST_ONE, - }); - - expect(changedPlaylist).toMatchObject({ - correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, - invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER, - }); - expect(laterUpsert).toMatchObject({ - correlationState: - PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, - invalidReason: null, - }); + ({ advance } = createCorrelationHarness()); }); it('invalidates a correlated sequence when a target call errors', () => { @@ -232,7 +36,7 @@ describe('preload performance marker correlation completions', () => { advance({ ipcCallId: 2, method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, - operationId: null, + operationId: OPERATION_ONE, phase: 'start', playlistId: PLAYLIST_ONE, }); @@ -240,14 +44,14 @@ describe('preload performance marker correlation completions', () => { const error = advance({ ipcCallId: 2, method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, - operationId: null, + operationId: OPERATION_ONE, phase: 'error', playlistId: PLAYLIST_ONE, }); const laterUpsert = advance({ ipcCallId: 3, method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, - operationId: null, + operationId: OPERATION_ONE, phase: 'start', playlistId: PLAYLIST_ONE, }); @@ -268,6 +72,7 @@ describe('preload performance marker correlation completions', () => { correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, invalidReason: null, + operationId: OPERATION_ONE, }); expect(restart.correlationState).toBe( PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED @@ -294,7 +99,7 @@ describe('preload performance marker correlation completions', () => { const laterGet = advance({ ipcCallId: 2, method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, - operationId: null, + operationId: OPERATION_ONE, phase: 'start', playlistId: PLAYLIST_ONE, }); @@ -308,7 +113,7 @@ describe('preload performance marker correlation completions', () => { correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, invalidReason: null, - operationId: null, + operationId: OPERATION_ONE, }); }); }); diff --git a/apps/electron-backend/src/app/api/preload-performance-correlation.identity.spec.ts b/apps/electron-backend/src/app/api/preload-performance-correlation.identity.spec.ts new file mode 100644 index 000000000..aec9e4c7b --- /dev/null +++ b/apps/electron-backend/src/app/api/preload-performance-correlation.identity.spec.ts @@ -0,0 +1,183 @@ +import { + PRELOAD_PERFORMANCE_CORRELATION_STATE, + PRELOAD_PERFORMANCE_INVALID_REASON, + PRELOAD_PERFORMANCE_METHOD, +} from '@iptvnator/shared/interfaces'; +import { + createCorrelationHarness, + OPERATION_ONE, + OPERATION_TWO, + PLAYLIST_ONE, + PLAYLIST_TWO, +} from './preload-performance-correlation.test-helpers'; + +describe('preload performance correlation identities', () => { + it('advances only database calls tagged with the refresh operation', () => { + const { advance } = createCorrelationHarness(); + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + + const unrelated = [ + advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: null, + phase: 'start', + playlistId: PLAYLIST_ONE, + }), + advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: null, + phase: 'success', + playlistId: PLAYLIST_ONE, + }), + advance({ + ipcCallId: 3, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId: OPERATION_TWO, + phase: 'start', + playlistId: PLAYLIST_ONE, + }), + advance({ + ipcCallId: 3, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId: OPERATION_TWO, + phase: 'success', + playlistId: PLAYLIST_ONE, + }), + ]; + + expect( + unrelated.every( + ({ correlationState }) => + correlationState === + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED + ) + ).toBe(true); + + for (const event of [ + { + ipcCallId: 4, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + phase: 'start', + }, + { + ipcCallId: 4, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + phase: 'success', + }, + { + ipcCallId: 5, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + phase: 'start', + }, + ] as const) { + advance({ + ...event, + operationId: OPERATION_ONE, + playlistId: PLAYLIST_ONE, + }); + } + const complete = advance({ + ipcCallId: 5, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + + expect(complete).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.COMPLETE, + operationId: OPERATION_ONE, + playlistId: PLAYLIST_ONE, + }); + }); + + it.each([ + { + label: 'operation', + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: OPERATION_TWO, + playlistId: PLAYLIST_ONE, + }, + { + label: 'playlist', + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: OPERATION_ONE, + playlistId: PLAYLIST_TWO, + }, + { + label: 'method', + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId: OPERATION_ONE, + playlistId: PLAYLIST_ONE, + }, + ])( + 'fails closed when the completion changes the started $label', + ({ method, operationId, playlistId }) => { + const { advance } = createCorrelationHarness(); + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + const completion = advance({ + ipcCallId: 2, + method, + operationId, + phase: 'success', + playlistId, + }); + const laterUpsert = advance({ + ipcCallId: 3, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + expect(completion).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: OPERATION_ONE, + playlistId: PLAYLIST_ONE, + }); + expect(laterUpsert).toMatchObject({ + correlationState: + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, + invalidReason: null, + }); + } + ); +}); diff --git a/apps/electron-backend/src/app/api/preload-performance-correlation.model.ts b/apps/electron-backend/src/app/api/preload-performance-correlation.model.ts index fe4ec4711..65c63c3cf 100644 --- a/apps/electron-backend/src/app/api/preload-performance-correlation.model.ts +++ b/apps/electron-backend/src/app/api/preload-performance-correlation.model.ts @@ -23,7 +23,6 @@ export type PreloadPerformanceSequenceStage = (typeof PRELOAD_PERFORMANCE_SEQUENCE_STAGE)[keyof typeof PRELOAD_PERFORMANCE_SEQUENCE_STAGE]; export interface PreloadPerformanceSequence { - activeCallId: number | null; invalidReason: PreloadPerformanceInvalidReason | null; operationId: string; stage: PreloadPerformanceSequenceStage; @@ -31,7 +30,6 @@ export interface PreloadPerformanceSequence { export interface PreloadPerformanceCall { correlationState: PreloadPerformanceCorrelationStateName; - expectedOperationId: string | null; invalidReason: PreloadPerformanceInvalidReason | null; method: PreloadPerformanceMethod; operationId: string | null; @@ -100,12 +98,10 @@ export function createInvalidPreloadPerformanceCall( event: PreloadPerformanceCorrelationEvent, playlistId: string | null, operationId: string | null, - invalidReason: PreloadPerformanceInvalidReason, - expectedOperationId = operationId + invalidReason: PreloadPerformanceInvalidReason ): PreloadPerformanceCall { return { correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, - expectedOperationId, invalidReason, method: event.method, operationId, @@ -130,7 +126,6 @@ export function invalidatePreloadPerformanceSequence( : reason; const invalidSequence: PreloadPerformanceSequence = { ...sequence, - activeCallId: null, invalidReason, stage: PRELOAD_PERFORMANCE_SEQUENCE_STAGE.INVALID, }; diff --git a/apps/electron-backend/src/app/api/preload-performance-correlation.sequence.spec.ts b/apps/electron-backend/src/app/api/preload-performance-correlation.sequence.spec.ts index 266c1c986..c73f50769 100644 --- a/apps/electron-backend/src/app/api/preload-performance-correlation.sequence.spec.ts +++ b/apps/electron-backend/src/app/api/preload-performance-correlation.sequence.spec.ts @@ -39,28 +39,28 @@ describe('preload performance marker correlation sequences', () => { const getStart = advance({ ipcCallId: 2, method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, - operationId: null, + operationId: OPERATION_ONE, phase: 'start', playlistId: PLAYLIST_ONE, }); const getSuccess = advance({ ipcCallId: 2, method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, - operationId: null, + operationId: OPERATION_ONE, phase: 'success', playlistId: PLAYLIST_ONE, }); const upsertStart = advance({ ipcCallId: 3, method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, - operationId: null, + operationId: OPERATION_ONE, phase: 'start', playlistId: PLAYLIST_ONE, }); const upsertSuccess = advance({ ipcCallId: 3, method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, - operationId: null, + operationId: OPERATION_ONE, phase: 'success', playlistId: PLAYLIST_ONE, }); @@ -104,7 +104,7 @@ describe('preload performance marker correlation sequences', () => { const wrongOrder = advance({ ipcCallId: 2, method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, - operationId: null, + operationId: OPERATION_ONE, phase: 'start', playlistId: PLAYLIST_ONE, }); @@ -133,21 +133,21 @@ describe('preload performance marker correlation sequences', () => { advance({ ipcCallId: 4, method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, - operationId: null, + operationId: OPERATION_ONE, phase: 'start', playlistId: PLAYLIST_ONE, }); const duplicate = advance({ ipcCallId: 5, method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, - operationId: null, + operationId: OPERATION_ONE, phase: 'start', playlistId: PLAYLIST_ONE, }); const originalCompletion = advance({ ipcCallId: 4, method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, - operationId: null, + operationId: OPERATION_ONE, phase: 'success', playlistId: PLAYLIST_ONE, }); @@ -253,7 +253,7 @@ describe('preload performance marker correlation sequences', () => { advance({ ipcCallId: 3, method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, - operationId: null, + operationId: OPERATION_ONE, phase: 'start', playlistId: PLAYLIST_ONE, }); @@ -261,7 +261,7 @@ describe('preload performance marker correlation sequences', () => { const playlistTwoGet = advance({ ipcCallId: 4, method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, - operationId: null, + operationId: OPERATION_TWO, phase: 'start', playlistId: PLAYLIST_TWO, }); @@ -293,28 +293,28 @@ describe('preload performance marker correlation sequences', () => { { ipcCallId: 2, method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, - operationId: null, + operationId: OPERATION_ONE, phase: 'start', playlistId: PLAYLIST_ONE, }, { ipcCallId: 2, method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, - operationId: null, + operationId: OPERATION_ONE, phase: 'success', playlistId: PLAYLIST_ONE, }, { ipcCallId: 3, method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, - operationId: null, + operationId: OPERATION_ONE, phase: 'start', playlistId: PLAYLIST_ONE, }, { ipcCallId: 3, method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, - operationId: null, + operationId: OPERATION_ONE, phase: 'success', playlistId: PLAYLIST_ONE, }, diff --git a/apps/electron-backend/src/app/api/preload-performance-correlation.start.ts b/apps/electron-backend/src/app/api/preload-performance-correlation.start.ts index 271c02e15..3363ba13c 100644 --- a/apps/electron-backend/src/app/api/preload-performance-correlation.start.ts +++ b/apps/electron-backend/src/app/api/preload-performance-correlation.start.ts @@ -14,6 +14,28 @@ import { type PreloadPerformanceSequence, } from './preload-performance-correlation.model'; +function rememberUncorrelatedCall( + calls: Map, + event: PreloadPerformanceCorrelationEvent, + playlistId: string, + operationId: string | null +): PreloadPerformanceMarkerMetadata { + calls.set(event.ipcCallId, { + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, + invalidReason: null, + method: event.method, + operationId, + playlistId, + }); + return createPreloadPerformanceMarkerMetadata( + event, + playlistId, + operationId, + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, + null + ); +} + function startRefresh( calls: Map, sequences: Map, @@ -21,28 +43,27 @@ function startRefresh( playlistId: string, operationId: string ): PreloadPerformanceMarkerMetadata { - const sequence = sequences.get(playlistId); - if (sequence) { + const existing = sequences.get(playlistId); + if (existing) { + const reason = + existing.stage === PRELOAD_PERFORMANCE_SEQUENCE_STAGE.INVALID + ? (existing.invalidReason ?? + PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER) + : PRELOAD_PERFORMANCE_INVALID_REASON.CONCURRENT_REFRESH; const invalidSequence = invalidatePreloadPerformanceSequence( calls, sequences, playlistId, - sequence.stage === PRELOAD_PERFORMANCE_SEQUENCE_STAGE.INVALID - ? (sequence.invalidReason ?? - PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER) - : PRELOAD_PERFORMANCE_INVALID_REASON.CONCURRENT_REFRESH + reason ); - const invalidReason = - invalidSequence?.invalidReason ?? - PRELOAD_PERFORMANCE_INVALID_REASON.CONCURRENT_REFRESH; + const invalidReason = invalidSequence?.invalidReason ?? reason; calls.set( event.ipcCallId, createInvalidPreloadPerformanceCall( event, playlistId, operationId, - invalidReason, - operationId + invalidReason ) ); return createPreloadPerformanceMarkerMetadata( @@ -55,14 +76,12 @@ function startRefresh( } sequences.set(playlistId, { - activeCallId: event.ipcCallId, invalidReason: null, operationId, stage: PRELOAD_PERFORMANCE_SEQUENCE_STAGE.REFRESH_STARTED, }); calls.set(event.ipcCallId, { correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, - expectedOperationId: operationId, invalidReason: null, method: event.method, operationId, @@ -81,26 +100,16 @@ function startDatabaseCall( calls: Map, sequences: Map, event: PreloadPerformanceCorrelationEvent, - playlistId: string + playlistId: string, + operationId: string | null ): PreloadPerformanceMarkerMetadata { + if (operationId === null) { + return rememberUncorrelatedCall(calls, event, playlistId, operationId); + } + const sequence = sequences.get(playlistId); - if (!sequence) { - calls.set(event.ipcCallId, { - correlationState: - PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, - expectedOperationId: null, - invalidReason: null, - method: event.method, - operationId: null, - playlistId, - }); - return createPreloadPerformanceMarkerMetadata( - event, - playlistId, - null, - PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, - null - ); + if (!sequence || sequence.operationId !== operationId) { + return rememberUncorrelatedCall(calls, event, playlistId, operationId); } if (sequence.stage === PRELOAD_PERFORMANCE_SEQUENCE_STAGE.INVALID) { @@ -112,15 +121,14 @@ function startDatabaseCall( createInvalidPreloadPerformanceCall( event, playlistId, - sequence.operationId, - invalidReason, - null + operationId, + invalidReason ) ); return createPreloadPerformanceMarkerMetadata( event, playlistId, - sequence.operationId, + operationId, PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, invalidReason ); @@ -134,6 +142,7 @@ function startDatabaseCall( const startedStage = isGet ? PRELOAD_PERFORMANCE_SEQUENCE_STAGE.DB_GET_STARTED : PRELOAD_PERFORMANCE_SEQUENCE_STAGE.DB_UPSERT_STARTED; + if (sequence.stage !== expectedStage) { const invalidSequence = invalidatePreloadPerformanceSequence( calls, @@ -149,15 +158,14 @@ function startDatabaseCall( createInvalidPreloadPerformanceCall( event, playlistId, - sequence.operationId, - invalidReason, - null + operationId, + invalidReason ) ); return createPreloadPerformanceMarkerMetadata( event, playlistId, - sequence.operationId, + operationId, PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, invalidReason ); @@ -165,21 +173,19 @@ function startDatabaseCall( sequences.set(playlistId, { ...sequence, - activeCallId: event.ipcCallId, stage: startedStage, }); calls.set(event.ipcCallId, { correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, - expectedOperationId: null, invalidReason: null, method: event.method, - operationId: sequence.operationId, + operationId, playlistId, }); return createPreloadPerformanceMarkerMetadata( event, playlistId, - sequence.operationId, + operationId, PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, null ); @@ -211,8 +217,7 @@ export function startPreloadPerformanceCall( event, playlistId, null, - PRELOAD_PERFORMANCE_INVALID_REASON.MALFORMED_IDENTIFIER, - operationId + PRELOAD_PERFORMANCE_INVALID_REASON.MALFORMED_IDENTIFIER ) ); return createPreloadPerformanceMarkerMetadata( @@ -224,28 +229,7 @@ export function startPreloadPerformanceCall( ); } - const existingCall = calls.get(event.ipcCallId); - if (existingCall) { - const sequence = - existingCall.playlistId === null - ? undefined - : invalidatePreloadPerformanceSequence( - calls, - sequences, - existingCall.playlistId, - PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER - ); - return createPreloadPerformanceMarkerMetadata( - event, - playlistId, - sequence?.operationId ?? null, - PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, - sequence?.invalidReason ?? - PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER - ); - } - return event.method === PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST ? startRefresh(calls, sequences, event, playlistId, operationId) - : startDatabaseCall(calls, sequences, event, playlistId); + : startDatabaseCall(calls, sequences, event, playlistId, operationId); } diff --git a/apps/electron-backend/src/app/api/preload-performance-correlation.ts b/apps/electron-backend/src/app/api/preload-performance-correlation.ts index 05a7de9e4..a42c82749 100644 --- a/apps/electron-backend/src/app/api/preload-performance-correlation.ts +++ b/apps/electron-backend/src/app/api/preload-performance-correlation.ts @@ -1,4 +1,3 @@ -import { PRELOAD_PERFORMANCE_METHOD } from '@iptvnator/shared/interfaces'; import { completePreloadPerformanceCall } from './preload-performance-correlation.complete'; import { normalizePreloadPerformanceIdentifier, @@ -29,10 +28,9 @@ export function advancePreloadPerformanceCorrelation( const calls = new Map(state.calls); const sequences = new Map(state.sequences); const playlistId = normalizePreloadPerformanceIdentifier(event.playlistId); - const operationId = - event.method === PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST - ? normalizePreloadPerformanceIdentifier(event.operationId) - : null; + const operationId = normalizePreloadPerformanceIdentifier( + event.operationId + ); const marker = event.phase === 'start' ? startPreloadPerformanceCall( diff --git a/libs/m3u-state/src/lib/actions.ts b/libs/m3u-state/src/lib/actions.ts index 9fcdaf2ca..3ff7df791 100644 --- a/libs/m3u-state/src/lib/actions.ts +++ b/libs/m3u-state/src/lib/actions.ts @@ -1,5 +1,10 @@ import { createActionGroup, emptyProps, props } from '@ngrx/store'; -import { Channel, EpgProgram, Playlist, PlaylistMeta } from '@iptvnator/shared/interfaces'; +import { + Channel, + EpgProgram, + Playlist, + PlaylistMeta, +} from '@iptvnator/shared/interfaces'; export const PlaylistActions = createActionGroup({ source: 'Playlists', @@ -11,6 +16,8 @@ export const PlaylistActions = createActionGroup({ 'Remove Playlist': props<{ playlistId: string }>(), 'Update Playlist Meta': props<{ playlist: PlaylistMeta }>(), 'Update Playlist': props<{ + /** Instrumentation-only; never persisted or sent to main/worker IPC. */ + operationId?: string; playlist: Playlist; playlistId: string; refreshEpg?: boolean; diff --git a/libs/m3u-state/src/lib/effects.ts b/libs/m3u-state/src/lib/effects.ts index ae0e5f0f5..d827eb6cd 100644 --- a/libs/m3u-state/src/lib/effects.ts +++ b/libs/m3u-state/src/lib/effects.ts @@ -55,6 +55,7 @@ import { } from './external-player-payload.util'; import { resolvePlaylistScopedEpgFetchPlan } from './playlist-scoped-epg-fetch.util'; import { resolveActiveEpgProgramAction } from './resolve-active-epg-program.util'; +import { persistPlaylistUpdate } from './playlist-update.effect-handler'; @Injectable({ providedIn: 'any' }) export class PlaylistEffects { @@ -305,10 +306,7 @@ export class PlaylistEffects { return this.actions$.pipe( ofType(PlaylistActions.updatePlaylist), switchMap((action) => - this.playlistsService.updatePlaylist(action.playlistId, { - ...action.playlist, - _id: action.playlistId, - }).pipe( + persistPlaylistUpdate(this.playlistsService, action).pipe( tap(() => { this.fetchPlaylistScopedEpg(action.playlist, { force: action.refreshEpg === true, @@ -353,12 +351,14 @@ export class PlaylistEffects { if ('isTemporary' in action && action.isTemporary) { return EMPTY; } - return this.playlistsService.addPlaylist(action.playlist).pipe( - tap(() => { - this.fetchPlaylistScopedEpg(action.playlist); - this.navigateToPlaylist(action.playlist); - }) - ); + return this.playlistsService + .addPlaylist(action.playlist) + .pipe( + tap(() => { + this.fetchPlaylistScopedEpg(action.playlist); + this.navigateToPlaylist(action.playlist); + }) + ); }) ); }, @@ -370,17 +370,21 @@ export class PlaylistEffects { return this.actions$.pipe( ofType(PlaylistActions.updatePlaylistMeta), switchMap((action) => - this.playlistsService.updatePlaylistMeta(action.playlist).pipe( - tap(() => { - if ( - this.hasPlaylistScopedEpgSourceChange( - action.playlist - ) - ) { - this.fetchPlaylistScopedEpg(action.playlist); - } - }) - ) + this.playlistsService + .updatePlaylistMeta(action.playlist) + .pipe( + tap(() => { + if ( + this.hasPlaylistScopedEpgSourceChange( + action.playlist + ) + ) { + this.fetchPlaylistScopedEpg( + action.playlist + ); + } + }) + ) ) ); }, diff --git a/libs/m3u-state/src/lib/playlist-update.effect-handler.ts b/libs/m3u-state/src/lib/playlist-update.effect-handler.ts new file mode 100644 index 000000000..899fb349f --- /dev/null +++ b/libs/m3u-state/src/lib/playlist-update.effect-handler.ts @@ -0,0 +1,29 @@ +import type { Playlist } from '@iptvnator/shared/interfaces'; + +interface PlaylistUpdateAction { + operationId?: string; + playlist: Playlist; + playlistId: string; +} + +interface PlaylistUpdater { + updatePlaylist: ( + playlistId: string, + playlist: Playlist, + operationId?: string + ) => TResult; +} + +export function persistPlaylistUpdate( + playlistsService: PlaylistUpdater, + action: PlaylistUpdateAction +): TResult { + return playlistsService.updatePlaylist( + action.playlistId, + { + ...action.playlist, + _id: action.playlistId, + }, + action.operationId + ); +} diff --git a/libs/m3u-state/src/lib/playlist-update.effect.spec.ts b/libs/m3u-state/src/lib/playlist-update.effect.spec.ts new file mode 100644 index 000000000..6041442a3 --- /dev/null +++ b/libs/m3u-state/src/lib/playlist-update.effect.spec.ts @@ -0,0 +1,30 @@ +import { type Playlist } from '@iptvnator/shared/interfaces'; +import { of } from 'rxjs'; +import { PlaylistActions } from './actions'; +import { persistPlaylistUpdate } from './playlist-update.effect-handler'; + +describe('PlaylistEffects updatePlaylist', () => { + it('forwards the refresh operation ID to playlist persistence', () => { + const playlistsService = { + updatePlaylist: jest.fn(() => of(undefined)), + }; + const playlist = { + _id: 'playlist-1', + playlist: { items: [] }, + } as unknown as Playlist; + const action = PlaylistActions.updatePlaylist({ + playlist, + playlistId: playlist._id, + refreshEpg: true, + operationId: 'playlist-refresh-operation', + }); + + persistPlaylistUpdate(playlistsService, action); + + expect(playlistsService.updatePlaylist).toHaveBeenCalledWith( + playlist._id, + playlist, + 'playlist-refresh-operation' + ); + }); +}); diff --git a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts index 76ea80fa1..2ba8bb51e 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts @@ -630,6 +630,7 @@ describe('PlaylistRefreshActionService', () => { playlist: refreshedPlaylist, playlistId: item._id, refreshEpg: true, + operationId: 'playlist-refresh-op', }) ); }); diff --git a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts index 8ca73a67a..43aed4ab6 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts @@ -205,12 +205,11 @@ export class PlaylistRefreshActionService { } try { + const operationId = + this.databaseService.createOperationId('playlist-refresh'); const refreshedPlaylist = await this.playlistRefreshService.refreshPlaylist({ - operationId: - this.databaseService.createOperationId( - 'playlist-refresh' - ), + operationId, playlistId: item._id, title: item.title, url: item.url, @@ -228,6 +227,7 @@ export class PlaylistRefreshActionService { }, playlistId: item._id, refreshEpg: true, + operationId, }) ); diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts index ff7e04cf1..dca8d0fa0 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts @@ -549,5 +549,15 @@ describe('RecentPlaylistsComponent busy state', () => { expect(dataService.sendIpcEvent).not.toHaveBeenCalled(); await Promise.resolve(); + expect(store.dispatch).toHaveBeenCalledWith( + PlaylistActions.updatePlaylist({ + playlist: expect.objectContaining({ + _id: item._id, + }), + playlistId: item._id, + refreshEpg: true, + operationId: 'playlist-refresh-op', + }) + ); }); }); diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts index a615e96ec..bbada321d 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts @@ -463,6 +463,7 @@ export class RecentPlaylistsComponent { }, playlistId: item._id, refreshEpg: true, + operationId, }) ); diff --git a/libs/services/src/lib/playlists.service.spec.ts b/libs/services/src/lib/playlists.service.spec.ts index fa269989d..f240dd9fd 100644 --- a/libs/services/src/lib/playlists.service.spec.ts +++ b/libs/services/src/lib/playlists.service.spec.ts @@ -1336,6 +1336,37 @@ describe('PlaylistsService', () => { expect(electron.dbUpsertAppPlaylist).toHaveBeenCalledTimes(2); }); + it('forwards refresh instrumentation only to its SQLite read and write', async () => { + const { electron } = createStatefulElectronStore( + createBasePlaylist('playlist-instrumented-refresh') + ); + testWindow.electron = electron; + const service = createService(); + const operationId = 'playlist-refresh-operation'; + + await firstValueFrom( + service.updatePlaylist( + 'playlist-instrumented-refresh', + { + _id: 'playlist-instrumented-refresh', + playlist: { items: [] }, + } as Playlist, + operationId + ) + ); + + expect(electron.dbGetAppPlaylist).toHaveBeenCalledWith( + 'playlist-instrumented-refresh', + operationId + ); + expect(electron.dbUpsertAppPlaylist).toHaveBeenCalledWith( + expect.objectContaining({ + _id: 'playlist-instrumented-refresh', + }), + operationId + ); + }); + it('keeps both favorites when two rapid favorite adds overlap (SQLite)', async () => { const { store, electron } = createStatefulElectronStore( createBasePlaylist('portal-race-two-favorites') diff --git a/libs/services/src/lib/playlists.service.ts b/libs/services/src/lib/playlists.service.ts index 9233d8d3c..85b332a10 100644 --- a/libs/services/src/lib/playlists.service.ts +++ b/libs/services/src/lib/playlists.service.ts @@ -52,7 +52,10 @@ type PlaylistRawItem = { type PlaylistStorageElectronApi = { dbDeleteAllPlaylists: () => Promise; dbDeletePlaylist: (playlistId: string) => Promise; - dbGetAppPlaylist: (playlistId: string) => Promise; + dbGetAppPlaylist: ( + playlistId: string, + operationId?: string + ) => Promise; dbGetAppPlaylistFavoriteChannels?: ( playlistId: string ) => Promise; @@ -60,7 +63,10 @@ type PlaylistStorageElectronApi = { dbGetAppPlaylists: () => Promise; dbGetAppState: (key: string) => Promise; dbSetAppState: (key: string, value: string) => Promise; - dbUpsertAppPlaylist: (playlist: Playlist) => Promise; + dbUpsertAppPlaylist: ( + playlist: Playlist, + operationId?: string + ) => Promise; dbUpsertAppPlaylists: (playlists: Playlist[]) => Promise; }; @@ -362,14 +368,18 @@ export class PlaylistsService { } } - private upsertSqlitePlaylist(playlist: Playlist) { + private upsertSqlitePlaylist(playlist: Playlist, operationId?: string) { return this.runOnSqlite(async () => { const electron = this.electronApi; if (!electron) { return playlist; } - await electron.dbUpsertAppPlaylist(playlist); + if (operationId === undefined) { + await electron.dbUpsertAppPlaylist(playlist); + } else { + await electron.dbUpsertAppPlaylist(playlist, operationId); + } return playlist; }); } @@ -411,9 +421,14 @@ export class PlaylistsService { }); } - private persistPlaylistMutation(nextPlaylist: Playlist) { + private persistPlaylistMutation( + nextPlaylist: Playlist, + operationId?: string + ) { if (this.isElectronStorageAvailable) { - return firstValueFrom(this.upsertSqlitePlaylist(nextPlaylist)); + return firstValueFrom( + this.upsertSqlitePlaylist(nextPlaylist, operationId) + ); } return firstValueFrom( @@ -554,10 +569,14 @@ export class PlaylistsService { }; } - updatePlaylist(playlistId: string, updatedPlaylist: Playlist) { + updatePlaylist( + playlistId: string, + updatedPlaylist: Playlist, + operationId?: string + ) { return this.serializePlaylistWrite(playlistId, async () => { const currentPlaylist = await firstValueFrom( - this.getPlaylistById(playlistId) + this.getPlaylistById(playlistId, operationId) ); const mergedPlaylist = this.mergeRefreshedPlaylist( currentPlaylist, @@ -565,17 +584,21 @@ export class PlaylistsService { playlistId ); - return this.persistPlaylistMutation(mergedPlaylist); + return this.persistPlaylistMutation(mergedPlaylist, operationId); }); } - getPlaylistById(id: string) { + getPlaylistById(id: string, operationId?: string) { if (this.isElectronStorageAvailable) { return this.runOnSqlite(async () => { const electron = this.electronApi; - const playlist = electron - ? await electron.dbGetAppPlaylist(id) - : null; + let playlist: Playlist | null = null; + if (electron) { + playlist = + operationId === undefined + ? await electron.dbGetAppPlaylist(id) + : await electron.dbGetAppPlaylist(id, operationId); + } return playlist ? this.createSqliteFallbackPlaylist(playlist as Playlist) : (undefined as unknown as Playlist); diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index 08214323f..3fd7dec36 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -684,13 +684,21 @@ export interface ElectronBridgeApi { dbGetPlaylist: ( playlistId: string ) => Promise; - dbUpsertAppPlaylist: (playlist: Playlist) => Promise; + dbUpsertAppPlaylist: ( + playlist: Playlist, + /** Instrumentation-only; never persisted or sent to main/worker IPC. */ + operationId?: string + ) => Promise; dbUpsertAppPlaylists: ( playlists: Playlist[] ) => Promise; dbGetAppPlaylists: () => Promise; dbGetAppPlaylistMetas: () => Promise; - dbGetAppPlaylist: (playlistId: string) => Promise; + dbGetAppPlaylist: ( + playlistId: string, + /** Instrumentation-only; never persisted or sent to main/worker IPC. */ + operationId?: string + ) => Promise; dbGetAppPlaylistFavoriteChannels: ( playlistId: string ) => Promise; From 0579d253c4e9a1273423c1be72cd76a3bef8bf4b Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 26 Jul 2026 19:49:17 +0200 Subject: [PATCH 5/9] fix(electron): fail closed on unknown performance completions --- ...reload-performance-correlation.complete.ts | 19 +- ...performance-correlation.completion.spec.ts | 192 ++++++++++++++++++ libs/m3u-state/src/lib/actions.ts | 5 +- ...=> playlist-update.effect-handler.spec.ts} | 2 +- .../src/lib/electron-api.interface.ts | 10 +- 5 files changed, 222 insertions(+), 6 deletions(-) rename libs/m3u-state/src/lib/{playlist-update.effect.spec.ts => playlist-update.effect-handler.spec.ts} (95%) diff --git a/apps/electron-backend/src/app/api/preload-performance-correlation.complete.ts b/apps/electron-backend/src/app/api/preload-performance-correlation.complete.ts index b27e327b3..e8dc4e699 100644 --- a/apps/electron-backend/src/app/api/preload-performance-correlation.complete.ts +++ b/apps/electron-backend/src/app/api/preload-performance-correlation.complete.ts @@ -168,12 +168,27 @@ export function completePreloadPerformanceCall( ): PreloadPerformanceMarkerMetadata { const call = calls.get(event.ipcCallId); if (!call) { + const invalidSequence = + playlistId === null + ? undefined + : invalidatePreloadPerformanceSequence( + calls, + sequences, + playlistId, + PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER + ); + closeInvalidPreloadPerformanceSequenceWhenSettled( + calls, + sequences, + playlistId + ); return createPreloadPerformanceMarkerMetadata( event, playlistId, - operationId, + invalidSequence?.operationId ?? operationId, PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, - PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER + invalidSequence?.invalidReason ?? + PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER ); } diff --git a/apps/electron-backend/src/app/api/preload-performance-correlation.completion.spec.ts b/apps/electron-backend/src/app/api/preload-performance-correlation.completion.spec.ts index 6cde5c2cc..e10251ddd 100644 --- a/apps/electron-backend/src/app/api/preload-performance-correlation.completion.spec.ts +++ b/apps/electron-backend/src/app/api/preload-performance-correlation.completion.spec.ts @@ -8,9 +8,48 @@ import { OPERATION_ONE, OPERATION_TWO, PLAYLIST_ONE, + PLAYLIST_TWO, type CorrelationHarness, } from './preload-performance-correlation.test-helpers'; +function completeDatabaseSequence( + advance: CorrelationHarness['advance'], + firstCallId: number, + playlistId: string, + operationId: string +) { + return [ + advance({ + ipcCallId: firstCallId, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId, + phase: 'start', + playlistId, + }), + advance({ + ipcCallId: firstCallId, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId, + phase: 'success', + playlistId, + }), + advance({ + ipcCallId: firstCallId + 1, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId, + phase: 'start', + playlistId, + }), + advance({ + ipcCallId: firstCallId + 1, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId, + phase: 'success', + playlistId, + }), + ]; +} + describe('preload performance marker correlation completions', () => { let advance: CorrelationHarness['advance']; @@ -18,6 +57,159 @@ describe('preload performance marker correlation completions', () => { ({ advance } = createCorrelationHarness()); }); + it.each(['success', 'error'] as const)( + 'invalidates only the matching playlist after an unknown call ID %s', + (phase) => { + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + advance({ + ipcCallId: 10, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_TWO, + phase: 'start', + playlistId: PLAYLIST_TWO, + }); + + const unknownCompletion = advance({ + ipcCallId: 99, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_TWO, + phase, + playlistId: PLAYLIST_ONE, + }); + const matchingCompletion = advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + const samePlaylistPersistence = completeDatabaseSequence( + advance, + 20, + PLAYLIST_ONE, + OPERATION_ONE + ); + + const unrelatedRefreshCompletion = advance({ + ipcCallId: 10, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_TWO, + phase: 'success', + playlistId: PLAYLIST_TWO, + }); + const unrelatedPersistence = completeDatabaseSequence( + advance, + 30, + PLAYLIST_TWO, + OPERATION_TWO + ); + + expect(unknownCompletion).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER, + operationId: OPERATION_ONE, + playlistId: PLAYLIST_ONE, + }); + expect(matchingCompletion).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER, + }); + expect( + [matchingCompletion, ...samePlaylistPersistence].some( + ({ correlationState }) => + correlationState === + PRELOAD_PERFORMANCE_CORRELATION_STATE.COMPLETE + ) + ).toBe(false); + expect(unrelatedRefreshCompletion.correlationState).toBe( + PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED + ); + expect(unrelatedPersistence.at(-1)).toMatchObject({ + correlationState: + PRELOAD_PERFORMANCE_CORRELATION_STATE.COMPLETE, + operationId: OPERATION_TWO, + playlistId: PLAYLIST_TWO, + }); + } + ); + + it('invalidates persistence after a duplicate completion without touching another playlist', () => { + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + advance({ + ipcCallId: 10, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_TWO, + phase: 'start', + playlistId: PLAYLIST_TWO, + }); + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + + const duplicateCompletion = advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + const samePlaylistPersistence = completeDatabaseSequence( + advance, + 20, + PLAYLIST_ONE, + OPERATION_ONE + ); + + advance({ + ipcCallId: 10, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_TWO, + phase: 'success', + playlistId: PLAYLIST_TWO, + }); + const unrelatedPersistence = completeDatabaseSequence( + advance, + 30, + PLAYLIST_TWO, + OPERATION_TWO + ); + + expect(duplicateCompletion).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER, + operationId: OPERATION_ONE, + playlistId: PLAYLIST_ONE, + }); + expect( + samePlaylistPersistence.some( + ({ correlationState }) => + correlationState === + PRELOAD_PERFORMANCE_CORRELATION_STATE.COMPLETE + ) + ).toBe(false); + expect(unrelatedPersistence.at(-1)).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.COMPLETE, + operationId: OPERATION_TWO, + playlistId: PLAYLIST_TWO, + }); + }); + it('invalidates a correlated sequence when a target call errors', () => { advance({ ipcCallId: 1, diff --git a/libs/m3u-state/src/lib/actions.ts b/libs/m3u-state/src/lib/actions.ts index 3ff7df791..3f6008509 100644 --- a/libs/m3u-state/src/lib/actions.ts +++ b/libs/m3u-state/src/lib/actions.ts @@ -16,7 +16,10 @@ export const PlaylistActions = createActionGroup({ 'Remove Playlist': props<{ playlistId: string }>(), 'Update Playlist Meta': props<{ playlist: PlaylistMeta }>(), 'Update Playlist': props<{ - /** Instrumentation-only; never persisted or sent to main/worker IPC. */ + /** + * Instrumentation-only; stripped before the DB invoke. + * Never enters the DB worker payload or persisted playlist data. + */ operationId?: string; playlist: Playlist; playlistId: string; diff --git a/libs/m3u-state/src/lib/playlist-update.effect.spec.ts b/libs/m3u-state/src/lib/playlist-update.effect-handler.spec.ts similarity index 95% rename from libs/m3u-state/src/lib/playlist-update.effect.spec.ts rename to libs/m3u-state/src/lib/playlist-update.effect-handler.spec.ts index 6041442a3..3e80ff0d4 100644 --- a/libs/m3u-state/src/lib/playlist-update.effect.spec.ts +++ b/libs/m3u-state/src/lib/playlist-update.effect-handler.spec.ts @@ -3,7 +3,7 @@ import { of } from 'rxjs'; import { PlaylistActions } from './actions'; import { persistPlaylistUpdate } from './playlist-update.effect-handler'; -describe('PlaylistEffects updatePlaylist', () => { +describe('persistPlaylistUpdate', () => { it('forwards the refresh operation ID to playlist persistence', () => { const playlistsService = { updatePlaylist: jest.fn(() => of(undefined)), diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index 3fd7dec36..de3695b46 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -686,7 +686,10 @@ export interface ElectronBridgeApi { ) => Promise; dbUpsertAppPlaylist: ( playlist: Playlist, - /** Instrumentation-only; never persisted or sent to main/worker IPC. */ + /** + * Instrumentation-only; stripped before the DB invoke. + * Never enters the DB worker payload or persisted playlist data. + */ operationId?: string ) => Promise; dbUpsertAppPlaylists: ( @@ -696,7 +699,10 @@ export interface ElectronBridgeApi { dbGetAppPlaylistMetas: () => Promise; dbGetAppPlaylist: ( playlistId: string, - /** Instrumentation-only; never persisted or sent to main/worker IPC. */ + /** + * Instrumentation-only; stripped before the DB invoke. + * Never enters the DB worker payload or persisted playlist data. + */ operationId?: string ) => Promise; dbGetAppPlaylistFavoriteChannels: ( From da3b657277221b6caf0b2b9179fbae4903d303a7 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 26 Jul 2026 22:37:29 +0200 Subject: [PATCH 6/9] fix(perf): make worker profiling request scoped --- AGENTS.md | 2 +- CLAUDE.md | 2 +- .../database-request-identity-capture.spec.ts | 229 +++++++++++ .../database-request-identity-capture.ts | 270 +++++++++++++ .../m3u-refresh-cancellation-contract.ts | 44 ++- .../m3u-refresh-cancellation-report.ts | 108 +++-- .../performance/m3u-refresh-main-capture.ts | 362 ++++++++++------- ...orker-request-performance-envelope.spec.ts | 172 ++++++++ .../worker-request-performance.spec.ts | 302 ++++++++++++++ .../performance/worker-request-performance.ts | 302 ++++++++++++++ .../src/app/workers/database.worker.ts | 146 +++++-- .../app/workers/playlist-refresh.worker.ts | 98 +++-- .../worker-performance-cancellation.spec.ts | 284 +++++++++++++ ...er-performance-capture.concurrency.spec.ts | 95 +++++ ...rker-performance-capture.histogram.spec.ts | 39 ++ .../worker-performance-capture.histogram.ts | 136 +++++++ .../worker-performance-capture.metrics.ts | 235 +++++++++++ .../worker-performance-capture.model.ts | 94 +++++ .../worker-performance-capture.runtime.ts | 45 +++ .../worker-performance-capture.spec.ts | 373 +++++++++++++++++- ...worker-performance-capture.test-harness.ts | 158 ++++++++ .../app/workers/worker-performance-capture.ts | 285 +++++++++++-- .../worker-performance-integration.spec.ts | 118 ++++++ docs/architecture/m3u-playlist-module.md | 26 +- docs/architecture/sqlite-db-worker.md | 29 ++ 25 files changed, 3631 insertions(+), 323 deletions(-) create mode 100644 apps/electron-backend-e2e/src/performance/database-request-identity-capture.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/database-request-identity-capture.ts create mode 100644 apps/electron-backend-e2e/src/performance/worker-request-performance-envelope.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/worker-request-performance.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/worker-request-performance.ts create mode 100644 apps/electron-backend/src/app/workers/worker-performance-cancellation.spec.ts create mode 100644 apps/electron-backend/src/app/workers/worker-performance-capture.concurrency.spec.ts create mode 100644 apps/electron-backend/src/app/workers/worker-performance-capture.histogram.spec.ts create mode 100644 apps/electron-backend/src/app/workers/worker-performance-capture.histogram.ts create mode 100644 apps/electron-backend/src/app/workers/worker-performance-capture.metrics.ts create mode 100644 apps/electron-backend/src/app/workers/worker-performance-capture.model.ts create mode 100644 apps/electron-backend/src/app/workers/worker-performance-capture.runtime.ts create mode 100644 apps/electron-backend/src/app/workers/worker-performance-capture.test-harness.ts create mode 100644 apps/electron-backend/src/app/workers/worker-performance-integration.spec.ts diff --git a/AGENTS.md b/AGENTS.md index f3cd7a990..c1b428902 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,7 +85,7 @@ IPTVNATOR_TRACE_STARTUP=1 nx serve electron-backend - `IPTVNATOR_TRACE_PLAYER=1` traces external-player activity and bounded Embedded MPV runtime-probe stderr - `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console output into the Electron terminal - `IPTVNATOR_PERF_CAPTURE=1` enables development/test-only, redacted preload IPC phase markers for refresh/DB benchmark correlation; benchmark tooling sets it explicitly, and production launches must leave it unset - - `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only event-loop metrics in database and playlist-refresh worker responses; the performance benchmark sets it automatically, and production launches must leave it unset + - `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only, request-scoped worker timestamps, thread CPU, event-loop utilization, and event-loop delay metrics in database and playlist-refresh responses; overlapping database requests are explicitly invalidated instead of misattributed, the performance benchmark sets the flag automatically, and production launches must leave it unset - Settings, portal request/response, and trace payloads must use `@iptvnator/shared/logging` or the redacting portal logger before reaching diff --git a/CLAUDE.md b/CLAUDE.md index 72eed72c7..bd872ab79 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -142,7 +142,7 @@ Useful narrower flags: - `IPTVNATOR_TRACE_PLAYER=1` traces external-player activity and bounded Embedded MPV runtime-probe stderr - `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console logs into the Electron terminal - `IPTVNATOR_PERF_CAPTURE=1` enables development/test-only, redacted preload IPC phase markers for refresh/DB benchmark correlation; benchmark tooling sets it explicitly, and production launches must leave it unset -- `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only event-loop metrics in database and playlist-refresh worker responses; the performance benchmark sets it automatically, and production launches must leave it unset +- `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only, request-scoped worker timestamps, thread CPU, event-loop utilization, and event-loop delay metrics in database and playlist-refresh responses; overlapping database requests are explicitly invalidated instead of misattributed, the performance benchmark sets the flag automatically, and production launches must leave it unset Settings, portal request/response, and trace payloads must use `@iptvnator/shared/logging` or the redacting portal logger before reaching diff --git a/apps/electron-backend-e2e/src/performance/database-request-identity-capture.spec.ts b/apps/electron-backend-e2e/src/performance/database-request-identity-capture.spec.ts new file mode 100644 index 000000000..2e9672718 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/database-request-identity-capture.spec.ts @@ -0,0 +1,229 @@ +/* eslint-disable playwright/expect-expect -- These are Node assertion-based performance contract tests. */ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import test from 'node:test'; + +import { + installDatabaseRequestIdentityCaptureInMain, + type DatabaseRequestIdentityCaptureApi, +} from './database-request-identity-capture'; + +const CHANNEL = 'IPTVNATOR_PERF_CAPTURE_MARKER'; +let nextStateId = 1; + +function createCapture(): { + api: DatabaseRequestIdentityCaptureApi; + ipcMain: EventEmitter; +} { + const ipcMain = new EventEmitter(); + const stateKey = `__identityCapture${nextStateId}`; + nextStateId += 1; + installDatabaseRequestIdentityCaptureInMain({ ipcMain } as never, stateKey); + const api = (globalThis as unknown as Record)[ + stateKey + ] as DatabaseRequestIdentityCaptureApi; + api.start(); + return { api, ipcMain }; +} + +function marker(overrides: Record = {}) { + return { + correlationState: 'correlated', + invalidReason: null, + ipcCallId: 2, + method: 'dbGetAppPlaylist', + operationId: 'refresh-operation-1', + phase: 'start', + playlistId: 'playlist-1', + sourceEpochMs: 100, + ...overrides, + }; +} + +test('correlates the real preload marker to a DB request whose payload omits operationId', () => { + const { api, ipcMain } = createCapture(); + ipcMain.emit(CHANNEL, {}, marker()); + + const identity = api.matchDatabaseRequest({ + operation: 'DB_GET_APP_PLAYLIST', + payload: { playlistId: 'playlist-1' }, + requestId: 'request-1', + type: 'request', + }); + + assert.deepEqual(identity, { + operationId: 'refresh-operation-1', + operationIdUnavailableReason: null, + }); +}); + +test('resolves a response-retained request identity when the marker arrives later and resets between captures', () => { + const { api, ipcMain } = createCapture(); + const identity = api.matchDatabaseRequest({ + operation: 'DB_GET_APP_PLAYLIST', + payload: { playlistId: 'playlist-1' }, + requestId: 'request-before-marker', + type: 'request', + }); + assert.equal( + identity.operationIdUnavailableReason, + 'preload-performance-marker-missing' + ); + const responseOutcome = { identity }; + + ipcMain.emit(CHANNEL, {}, marker()); + assert.deepEqual(responseOutcome.identity, { + operationId: 'refresh-operation-1', + operationIdUnavailableReason: null, + }); + + api.stop(); + ipcMain.emit(CHANNEL, {}, marker({ operationId: 'marker-while-stopped' })); + api.start(); + const afterReset = api.matchDatabaseRequest({ + operation: 'DB_GET_APP_PLAYLIST', + payload: { playlistId: 'playlist-1' }, + requestId: 'request-after-reset', + type: 'request', + }); + assert.deepEqual(afterReset, { + operationId: null, + operationIdUnavailableReason: 'preload-performance-marker-missing', + }); +}); + +test('fails closed for missing and ambiguous preload markers', () => { + const missingCapture = createCapture(); + const missing = missingCapture.api.matchDatabaseRequest({ + operation: 'DB_GET_APP_PLAYLIST', + payload: { playlistId: 'playlist-1' }, + requestId: 'request-missing', + type: 'request', + }); + assert.deepEqual(missing, { + operationId: null, + operationIdUnavailableReason: 'preload-performance-marker-missing', + }); + + const ambiguousCapture = createCapture(); + ambiguousCapture.ipcMain.emit(CHANNEL, {}, marker()); + ambiguousCapture.ipcMain.emit( + CHANNEL, + {}, + marker({ + ipcCallId: 3, + operationId: 'refresh-operation-2', + }) + ); + const ambiguous = ambiguousCapture.api.matchDatabaseRequest({ + operation: 'DB_GET_APP_PLAYLIST', + payload: { playlistId: 'playlist-1' }, + requestId: 'request-ambiguous', + type: 'request', + }); + assert.deepEqual(ambiguous, { + operationId: null, + operationIdUnavailableReason: 'preload-performance-marker-ambiguous', + }); +}); + +test('quarantines a key after response-before-marker ambiguity', () => { + const { api, ipcMain } = createCapture(); + const requestA = api.matchDatabaseRequest({ + operation: 'DB_GET_APP_PLAYLIST', + payload: { playlistId: 'playlist-1' }, + requestId: 'request-a', + type: 'request', + }); + const requestB = api.matchDatabaseRequest({ + operation: 'DB_GET_APP_PLAYLIST', + payload: { playlistId: 'playlist-1' }, + requestId: 'request-b', + type: 'request', + }); + + ipcMain.emit( + CHANNEL, + {}, + marker({ ipcCallId: 2, operationId: 'refresh-operation-a' }) + ); + ipcMain.emit( + CHANNEL, + {}, + marker({ ipcCallId: 3, operationId: 'refresh-operation-b' }) + ); + + const requestC = api.matchDatabaseRequest({ + operation: 'DB_GET_APP_PLAYLIST', + payload: { playlistId: 'playlist-1' }, + requestId: 'request-c', + type: 'request', + }); + ipcMain.emit( + CHANNEL, + {}, + marker({ + ipcCallId: 4, + operationId: 'other-playlist-operation', + playlistId: 'playlist-2', + }) + ); + const otherPlaylist = api.matchDatabaseRequest({ + operation: 'DB_GET_APP_PLAYLIST', + payload: { playlistId: 'playlist-2' }, + requestId: 'other-playlist-request', + type: 'request', + }); + ipcMain.emit( + CHANNEL, + {}, + marker({ + ipcCallId: 5, + method: 'dbUpsertAppPlaylist', + operationId: 'other-db-operation', + }) + ); + const otherOperation = api.matchDatabaseRequest({ + operation: 'DB_UPSERT_APP_PLAYLIST', + payload: { _id: 'playlist-1' }, + requestId: 'other-operation-request', + type: 'request', + }); + + const ambiguousIdentity = { + operationId: null, + operationIdUnavailableReason: 'preload-performance-marker-ambiguous', + }; + assert.deepEqual(requestA, ambiguousIdentity); + assert.deepEqual(requestB, ambiguousIdentity); + assert.deepEqual(requestC, ambiguousIdentity); + assert.deepEqual(otherPlaylist, { + operationId: 'other-playlist-operation', + operationIdUnavailableReason: null, + }); + assert.deepEqual(otherOperation, { + operationId: 'other-db-operation', + operationIdUnavailableReason: null, + }); + + api.stop(); + api.start(); + ipcMain.emit( + CHANNEL, + {}, + marker({ + ipcCallId: 6, + operationId: 'fresh-capture-operation', + }) + ); + const freshCaptureIdentity = api.matchDatabaseRequest({ + operation: 'DB_GET_APP_PLAYLIST', + payload: { playlistId: 'playlist-1' }, + requestId: 'fresh-capture-request', + type: 'request', + }); + assert.deepEqual(freshCaptureIdentity, { + operationId: 'fresh-capture-operation', + operationIdUnavailableReason: null, + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/database-request-identity-capture.ts b/apps/electron-backend-e2e/src/performance/database-request-identity-capture.ts new file mode 100644 index 000000000..a0912d3e3 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/database-request-identity-capture.ts @@ -0,0 +1,270 @@ +import type { ElectronApplication } from '@playwright/test'; + +export const DATABASE_REQUEST_IDENTITY_CAPTURE_STATE_KEY = + '__iptvnatorM3uRefreshDatabaseRequestIdentityCapture'; + +export interface DatabaseRequestIdentity { + operationId: string | null; + operationIdUnavailableReason: string | null; +} + +export interface DatabaseRequestIdentityCaptureApi { + matchDatabaseRequest(message: unknown): DatabaseRequestIdentity; + start(): void; + stop(): void; +} + +interface DatabaseRequestIdentityElectron { + readonly ipcMain: { + on( + channel: string, + listener: (event: unknown, marker: unknown) => void + ): void; + }; +} + +export function installDatabaseRequestIdentityCaptureInMain( + { ipcMain }: DatabaseRequestIdentityElectron, + stateKey: string +): void { + type JsonRecord = Record; + interface PendingMarker { + readonly ipcCallId: number; + readonly operation: string; + readonly operationId: string; + readonly playlistId: string; + } + interface PendingRequest { + readonly identity: DatabaseRequestIdentity; + readonly operation: string; + readonly playlistId: string; + } + interface QuarantinedKey { + readonly operation: string; + readonly playlistId: string; + } + + const target = globalThis as unknown as Record; + if (target[stateKey] !== undefined) { + return; + } + + const markerChannel = 'IPTVNATOR_PERF_CAPTURE_MARKER'; + const markerMethodToOperation: Readonly> = { + dbGetAppPlaylist: 'DB_GET_APP_PLAYLIST', + dbUpsertAppPlaylist: 'DB_UPSERT_APP_PLAYLIST', + }; + let active = false; + let pendingMarkers: PendingMarker[] = []; + let pendingRequests: PendingRequest[] = []; + let quarantinedKeys: QuarantinedKey[] = []; + + const readRecord = (value: unknown): JsonRecord | null => + typeof value === 'object' && value !== null + ? (value as JsonRecord) + : null; + const readPlaylistId = ( + operation: string, + payload: unknown + ): string | null => { + const value = readRecord(payload); + if (!value) { + return null; + } + const playlistId = + operation === 'DB_UPSERT_APP_PLAYLIST' + ? value['_id'] + : value['playlistId']; + return typeof playlistId === 'string' && playlistId.length > 0 + ? playlistId + : null; + }; + const matches = ( + candidate: { operation: string; playlistId: string }, + operation: string, + playlistId: string + ): boolean => + candidate.operation === operation && + candidate.playlistId === playlistId; + const markAmbiguous = (requests: PendingRequest[]): void => { + for (const request of requests) { + request.identity.operationId = null; + request.identity.operationIdUnavailableReason = + 'preload-performance-marker-ambiguous'; + } + }; + const isQuarantined = (operation: string, playlistId: string): boolean => + quarantinedKeys.some((key) => matches(key, operation, playlistId)); + const quarantine = (operation: string, playlistId: string): void => { + if (!isQuarantined(operation, playlistId)) { + quarantinedKeys.push({ operation, playlistId }); + } + const requestCandidates = pendingRequests.filter((request) => + matches(request, operation, playlistId) + ); + markAmbiguous(requestCandidates); + pendingRequests = pendingRequests.filter( + (request) => !matches(request, operation, playlistId) + ); + pendingMarkers = pendingMarkers.filter( + (marker) => !matches(marker, operation, playlistId) + ); + }; + + ipcMain.on(markerChannel, (_event, input) => { + if (!active) { + return; + } + const marker = readRecord(input); + if ( + !marker || + marker['phase'] !== 'start' || + marker['correlationState'] !== 'correlated' || + marker['invalidReason'] !== null + ) { + return; + } + const operation = + typeof marker['method'] === 'string' + ? markerMethodToOperation[marker['method']] + : undefined; + const operationId = marker['operationId']; + const playlistId = marker['playlistId']; + const ipcCallId = marker['ipcCallId']; + if ( + operation === undefined || + typeof operationId !== 'string' || + operationId.length === 0 || + typeof playlistId !== 'string' || + playlistId.length === 0 || + !Number.isSafeInteger(ipcCallId) || + Number(ipcCallId) < 1 + ) { + return; + } + + if (isQuarantined(operation, playlistId)) { + return; + } + const requestCandidates = pendingRequests.filter((request) => + matches(request, operation, playlistId) + ); + if (requestCandidates.length === 1) { + const request = requestCandidates[0]; + if (!request) { + return; + } + request.identity.operationId = operationId; + request.identity.operationIdUnavailableReason = null; + pendingRequests = pendingRequests.filter( + (candidate) => candidate !== request + ); + return; + } + if (requestCandidates.length > 1) { + quarantine(operation, playlistId); + return; + } + + pendingMarkers.push({ + ipcCallId: Number(ipcCallId), + operation, + operationId, + playlistId, + }); + pendingMarkers.sort((left, right) => left.ipcCallId - right.ipcCallId); + }); + + const api: DatabaseRequestIdentityCaptureApi = { + matchDatabaseRequest: (input) => { + const message = readRecord(input); + const operation = message?.['operation']; + const playlistId = + typeof operation === 'string' + ? readPlaylistId(operation, message?.['payload']) + : null; + if ( + !active || + (operation !== 'DB_GET_APP_PLAYLIST' && + operation !== 'DB_UPSERT_APP_PLAYLIST') || + playlistId === null + ) { + return { + operationId: null, + operationIdUnavailableReason: + 'preload-performance-marker-not-applicable', + }; + } + + if (isQuarantined(operation, playlistId)) { + return { + operationId: null, + operationIdUnavailableReason: + 'preload-performance-marker-ambiguous', + }; + } + const markerCandidates = pendingMarkers.filter((marker) => + matches(marker, operation, playlistId) + ); + if (markerCandidates.length === 1) { + const marker = markerCandidates[0]; + if (!marker) { + return { + operationId: null, + operationIdUnavailableReason: + 'preload-performance-marker-missing', + }; + } + pendingMarkers = pendingMarkers.filter( + (candidate) => candidate !== marker + ); + return { + operationId: marker.operationId, + operationIdUnavailableReason: null, + }; + } + if (markerCandidates.length > 1) { + quarantine(operation, playlistId); + return { + operationId: null, + operationIdUnavailableReason: + 'preload-performance-marker-ambiguous', + }; + } + + const identity: DatabaseRequestIdentity = { + operationId: null, + operationIdUnavailableReason: + 'preload-performance-marker-missing', + }; + pendingRequests.push({ + identity, + operation, + playlistId, + }); + return identity; + }, + start: () => { + pendingMarkers = []; + pendingRequests = []; + quarantinedKeys = []; + active = true; + }, + stop: () => { + active = false; + pendingMarkers = []; + pendingRequests = []; + quarantinedKeys = []; + }, + }; + target[stateKey] = api; +} + +export async function installDatabaseRequestIdentityCapture( + electronApp: ElectronApplication +): Promise { + await electronApp.evaluate( + installDatabaseRequestIdentityCaptureInMain, + DATABASE_REQUEST_IDENTITY_CAPTURE_STATE_KEY + ); +} diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-contract.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-contract.ts index f0abcd22f..344767515 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-contract.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-contract.ts @@ -48,6 +48,29 @@ export interface MainTimelineRecord { readonly type: string; } +export interface WorkerRequestPerformanceMetrics { + readonly eventLoopDelay: EventLoopDelayMetrics | null; + readonly eventLoopDelayUnavailableReason: string | null; + readonly eventLoopUtilization: number | null; + readonly eventLoopUtilizationUnavailableReason: string | null; + readonly histogramFlushedEpochMs: number | null; + readonly invalidReason: string | null; + readonly operation: string | null; + readonly operationId: string | null; + readonly operationIdUnavailableReason: string | null; + readonly performanceCaptureUnavailableReason: string | null; + readonly playlistId: string | null; + readonly requestId: string | null; + readonly requestReceivedEpochMs: number | null; + readonly responseEpochMs: number; + readonly success: boolean; + readonly threadCpuSystemMicros: number | null; + readonly threadCpuUnavailableReason: string | null; + readonly threadCpuUserMicros: number | null; + readonly workEndedEpochMs: number | null; + readonly workStartedEpochMs: number | null; +} + export interface WorkerCaptureMetrics { readonly cancelPostedEpochMs: number | null; readonly cpuSystemMicros: number | null; @@ -62,6 +85,7 @@ export interface WorkerCaptureMetrics { readonly playlistId: string | null; readonly postGcHeapUsedBytes: number | null; readonly profilePath: string | null; + readonly requests: readonly WorkerRequestPerformanceMetrics[]; readonly responseEpochMs: number | null; readonly snapshotPath: string | null; readonly terminatedEpochMs: number | null; @@ -176,13 +200,15 @@ export interface CancellationBenchmarkSummary { readonly cancelToDurableTerminalMs: NumericDistribution; readonly cancelToWorkerTerminatedMs: NumericDistribution; readonly cancelTransportLatencyMs: NumericDistribution; - readonly databaseWorkerEventLoopDelayMaxMs: NumericDistribution; - readonly databaseWorkerEventLoopDelayP95Ms: NumericDistribution; - readonly databaseWorkerEventLoopDelayP99Ms: NumericDistribution; - readonly databaseWorkerEventLoopUtilization: NumericDistribution; readonly databaseWorkerExternalPeakBytes: NumericDistribution; readonly databaseWorkerHeapPeakBytes: NumericDistribution; readonly databaseWorkerPostGcHeapBytes: NumericDistribution; + readonly databaseWorkerRequestEventLoopDelayMaxMs: NumericDistribution; + readonly databaseWorkerRequestEventLoopDelayP95Ms: NumericDistribution; + readonly databaseWorkerRequestEventLoopDelayP99Ms: NumericDistribution; + readonly databaseWorkerRequestEventLoopUtilization: NumericDistribution; + readonly databaseWorkerRequestThreadCpuSystemMicros: NumericDistribution; + readonly databaseWorkerRequestThreadCpuUserMicros: NumericDistribution; readonly dataFetchMs: NumericDistribution; readonly dbCommitProxyMs: NumericDistribution; readonly ipcStoreDispatchProxyMs: NumericDistribution; @@ -196,13 +222,15 @@ export interface CancellationBenchmarkSummary { readonly mainRssPostGcBytes: NumericDistribution; readonly parseNormalizeCloneProxyMs: NumericDistribution; readonly persistencePreparationProxyMs: NumericDistribution; - readonly playlistWorkerEventLoopUtilization: NumericDistribution; - readonly playlistWorkerEventLoopDelayMaxMs: NumericDistribution; - readonly playlistWorkerEventLoopDelayP95Ms: NumericDistribution; - readonly playlistWorkerEventLoopDelayP99Ms: NumericDistribution; readonly playlistWorkerExternalPeakBytes: NumericDistribution; readonly playlistWorkerHeapPeakBytes: NumericDistribution; readonly playlistWorkerPostGcHeapBytes: NumericDistribution; + readonly playlistWorkerRequestEventLoopDelayMaxMs: NumericDistribution; + readonly playlistWorkerRequestEventLoopDelayP95Ms: NumericDistribution; + readonly playlistWorkerRequestEventLoopDelayP99Ms: NumericDistribution; + readonly playlistWorkerRequestEventLoopUtilization: NumericDistribution; + readonly playlistWorkerRequestThreadCpuSystemMicros: NumericDistribution; + readonly playlistWorkerRequestThreadCpuUserMicros: NumericDistribution; readonly rendererFrameGapMs: NumericDistribution; readonly rendererHeartbeatDelayMs: NumericDistribution; readonly rendererHeapPeakBytes: NumericDistribution; diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-report.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-report.ts index 2f063cd0a..7a8e4c220 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-report.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-report.ts @@ -9,6 +9,7 @@ import type { NumericDistribution, PerformanceWorkerKind, RendererCaptureMetrics, + WorkerRequestPerformanceMetrics, } from './m3u-refresh-cancellation-contract'; import { PERFORMANCE_ITERATION_KIND, @@ -63,15 +64,28 @@ export function createCancellationBenchmarkSummary( return worker ? select(worker) : null; }) ); - const workerEventLoopDelayMetric = ( + const workerRequestMetric = ( + kind: PerformanceWorkerKind, + select: (request: WorkerRequestPerformanceMetrics) => number | null + ): NumericDistribution => + summarizeNumbers( + measured.flatMap((iteration) => + iteration.main.workers + .filter((worker) => worker.kind === kind) + .flatMap((worker) => + worker.requests.map((request) => select(request)) + ) + ) + ); + const workerRequestEventLoopDelayMetric = ( kind: PerformanceWorkerKind, percentile: keyof NonNullable< - MainCaptureMetrics['workers'][number]['eventLoopDelay'] + WorkerRequestPerformanceMetrics['eventLoopDelay'] > ): NumericDistribution => - workerMetric( + workerRequestMetric( kind, - (worker) => worker.eventLoopDelay?.[percentile] ?? null + (request) => request.eventLoopDelay?.[percentile] ?? null ); return Object.freeze({ @@ -105,22 +119,6 @@ export function createCancellationBenchmarkSummary( (iteration) => iteration.phases.cancelTransportLatencyMs ) ), - databaseWorkerEventLoopDelayMaxMs: workerEventLoopDelayMetric( - PERFORMANCE_WORKER_KIND.DATABASE, - 'maxMs' - ), - databaseWorkerEventLoopDelayP95Ms: workerEventLoopDelayMetric( - PERFORMANCE_WORKER_KIND.DATABASE, - 'p95Ms' - ), - databaseWorkerEventLoopDelayP99Ms: workerEventLoopDelayMetric( - PERFORMANCE_WORKER_KIND.DATABASE, - 'p99Ms' - ), - databaseWorkerEventLoopUtilization: workerMetric( - PERFORMANCE_WORKER_KIND.DATABASE, - (worker) => worker.eventLoopUtilization - ), databaseWorkerExternalPeakBytes: workerMetric( PERFORMANCE_WORKER_KIND.DATABASE, (worker) => worker.peakExternalBytes @@ -133,6 +131,33 @@ export function createCancellationBenchmarkSummary( PERFORMANCE_WORKER_KIND.DATABASE, (worker) => worker.postGcHeapUsedBytes ), + databaseWorkerRequestEventLoopDelayMaxMs: + workerRequestEventLoopDelayMetric( + PERFORMANCE_WORKER_KIND.DATABASE, + 'maxMs' + ), + databaseWorkerRequestEventLoopDelayP95Ms: + workerRequestEventLoopDelayMetric( + PERFORMANCE_WORKER_KIND.DATABASE, + 'p95Ms' + ), + databaseWorkerRequestEventLoopDelayP99Ms: + workerRequestEventLoopDelayMetric( + PERFORMANCE_WORKER_KIND.DATABASE, + 'p99Ms' + ), + databaseWorkerRequestEventLoopUtilization: workerRequestMetric( + PERFORMANCE_WORKER_KIND.DATABASE, + (request) => request.eventLoopUtilization + ), + databaseWorkerRequestThreadCpuSystemMicros: workerRequestMetric( + PERFORMANCE_WORKER_KIND.DATABASE, + (request) => request.threadCpuSystemMicros + ), + databaseWorkerRequestThreadCpuUserMicros: workerRequestMetric( + PERFORMANCE_WORKER_KIND.DATABASE, + (request) => request.threadCpuUserMicros + ), dataFetchMs: summarizeNumbers( measured.map((iteration) => iteration.phases.dataFetchMs) ), @@ -185,22 +210,6 @@ export function createCancellationBenchmarkSummary( iteration.phases.persistencePreparationProxyMs ) ), - playlistWorkerEventLoopUtilization: workerMetric( - PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, - (worker) => worker.eventLoopUtilization - ), - playlistWorkerEventLoopDelayMaxMs: workerEventLoopDelayMetric( - PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, - 'maxMs' - ), - playlistWorkerEventLoopDelayP95Ms: workerEventLoopDelayMetric( - PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, - 'p95Ms' - ), - playlistWorkerEventLoopDelayP99Ms: workerEventLoopDelayMetric( - PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, - 'p99Ms' - ), playlistWorkerExternalPeakBytes: workerMetric( PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, (worker) => worker.peakExternalBytes @@ -213,6 +222,33 @@ export function createCancellationBenchmarkSummary( PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, (worker) => worker.postGcHeapUsedBytes ), + playlistWorkerRequestEventLoopDelayMaxMs: + workerRequestEventLoopDelayMetric( + PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, + 'maxMs' + ), + playlistWorkerRequestEventLoopDelayP95Ms: + workerRequestEventLoopDelayMetric( + PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, + 'p95Ms' + ), + playlistWorkerRequestEventLoopDelayP99Ms: + workerRequestEventLoopDelayMetric( + PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, + 'p99Ms' + ), + playlistWorkerRequestEventLoopUtilization: workerRequestMetric( + PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, + (request) => request.eventLoopUtilization + ), + playlistWorkerRequestThreadCpuSystemMicros: workerRequestMetric( + PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, + (request) => request.threadCpuSystemMicros + ), + playlistWorkerRequestThreadCpuUserMicros: workerRequestMetric( + PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, + (request) => request.threadCpuUserMicros + ), rendererFrameGapMs: summarizeNumbers( flattenRenderer( (iteration) => iteration.renderer.probe.frameGapsMs diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-main-capture.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-main-capture.ts index fd9435445..f61fa12b4 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-refresh-main-capture.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-main-capture.ts @@ -1,7 +1,17 @@ /* eslint-disable max-lines -- The injected main-process protocol must remain self-contained for Playwright serialization. */ import type { ElectronApplication } from '@playwright/test'; +import { + DATABASE_REQUEST_IDENTITY_CAPTURE_STATE_KEY, + installDatabaseRequestIdentityCapture, + type DatabaseRequestIdentity, + type DatabaseRequestIdentityCaptureApi, +} from './database-request-identity-capture'; import type { MainCaptureMetrics } from './m3u-refresh-cancellation-contract'; +import { + selectMainCaptureGeneration, + type MainCaptureGenerationTransport, +} from './worker-request-performance'; const MAIN_CAPTURE_STATE_KEY = '__iptvnatorM3uRefreshMainCapture'; @@ -23,7 +33,13 @@ export interface MainCaptureStatus { export async function installMainCapture( electronApp: ElectronApplication ): Promise { - await electronApp.evaluate(async ({ app, BrowserWindow }, stateKey) => { + await installDatabaseRequestIdentityCapture(electronApp); + const captureStateKeys = { + databaseRequestIdentityStateKey: + DATABASE_REQUEST_IDENTITY_CAPTURE_STATE_KEY, + stateKey: MAIN_CAPTURE_STATE_KEY, + }; + await electronApp.evaluate(async ({ app, BrowserWindow }, input) => { type JsonRecord = Record; type WorkerTransferable = import('node:worker_threads').Transferable; interface CpuProfileHandle { @@ -42,6 +58,15 @@ export async function installMainCapture( idle: number; utilization: number; } + interface WorkerRequestPerformance { + identity: DatabaseRequestIdentity; + operation: string | null; + performanceCapture: unknown; + playlistId: string | null; + requestId: string | null; + responseEpochMs: number; + success: boolean; + } interface InstrumentedWorker { cpuUsage?(): Promise; getHeapSnapshot?(): Promise; @@ -62,15 +87,11 @@ export async function installMainCapture( } interface WorkerRecord { cancelPostedEpochMs: number | null; + captureGeneration: number | null; cpuFirst: WorkerCpuUsage | null; cpuLast: WorkerCpuUsage | null; elu: number | null; eluStart: WorkerElu | null; - eventLoopDelay: { - maxMs: number; - p95Ms: number; - p99Ms: number; - } | null; externalPeak: number; finalized: boolean; finalizing: Promise | null; @@ -81,6 +102,7 @@ export async function installMainCapture( postGcHeapUsed: number | null; profileHandle: Promise | null; profilePath: string | null; + requestPerformance: WorkerRequestPerformance[]; responseEpochMs: number | null; sampleBusy: boolean; sampleTimer: NodeJS.Timeout | null; @@ -99,9 +121,12 @@ export async function installMainCapture( } const target = globalThis as unknown as Record; - if (target[stateKey] !== undefined) { + if (target[input.stateKey] !== undefined) { return; } + const databaseRequestIdentityCapture = target[ + input.databaseRequestIdentityStateKey + ] as DatabaseRequestIdentityCaptureApi; const runtimeProcess = process as typeof process & { getBuiltinModule(id: string): unknown; @@ -132,6 +157,7 @@ export async function installMainCapture( const dbRequests = new Map< string, { + identity: DatabaseRequestIdentity; operation: string; playlistId: string | null; record: WorkerRecord; @@ -140,6 +166,7 @@ export async function installMainCapture( const state = { active: false, + captureGeneration: 0, cpuStart: null as NodeJS.CpuUsage | null, diagnostic: false, eventLoopDelay: null as ReturnType< @@ -200,6 +227,9 @@ export async function installMainCapture( const playlistId = value['playlistId'] ?? value['_id']; return typeof playlistId === 'string' ? playlistId : null; }; + const isCurrentCaptureRecord = (record: WorkerRecord): boolean => + state.active && + record.captureGeneration === state.captureGeneration; const createWorkerRecord = ( worker: InstrumentedWorker, kind: WorkerRecord['kind'] @@ -210,11 +240,11 @@ export async function installMainCapture( } const record: WorkerRecord = { cancelPostedEpochMs: null, + captureGeneration: null, cpuFirst: null, cpuLast: null, elu: null, eluStart: null, - eventLoopDelay: null, externalPeak: 0, finalized: false, finalizing: null, @@ -225,6 +255,7 @@ export async function installMainCapture( postGcHeapUsed: null, profileHandle: null, profilePath: null, + requestPerformance: [], responseEpochMs: null, sampleBusy: false, sampleTimer: null, @@ -238,30 +269,13 @@ export async function installMainCapture( return; } const message = incoming as JsonRecord; - const performanceCapture = readWorkerPerformance(message); - if (performanceCapture) { - record.eventLoopDelay = record.eventLoopDelay - ? { - maxMs: Math.max( - record.eventLoopDelay.maxMs, - performanceCapture.eventLoopDelay.maxMs - ), - p95Ms: Math.max( - record.eventLoopDelay.p95Ms, - performanceCapture.eventLoopDelay.p95Ms - ), - p99Ms: Math.max( - record.eventLoopDelay.p99Ms, - performanceCapture.eventLoopDelay.p99Ms - ), - } - : performanceCapture.eventLoopDelay; + if (!isCurrentCaptureRecord(record)) { + return; } if (record.kind === 'playlist-refresh.worker') { if (message['type'] === 'event') { const event = message['event'] as - | JsonRecord - | undefined; + JsonRecord | undefined; recordTimeline({ operationId: record.operationId ?? undefined, playlistId: record.playlistId ?? undefined, @@ -270,7 +284,20 @@ export async function installMainCapture( )}:${String(event?.['phase'] ?? 'none')}`, }); } else if (message['type'] === 'response') { - record.responseEpochMs = nowEpochMs(); + const responseEpochMs = nowEpochMs(); + record.responseEpochMs = responseEpochMs; + record.requestPerformance.push({ + identity: { + operationId: record.operationId, + operationIdUnavailableReason: null, + }, + operation: null, + performanceCapture: message['performance'] ?? null, + playlistId: record.playlistId, + requestId: null, + responseEpochMs, + success: message['success'] === true, + }); recordTimeline({ operationId: record.operationId ?? undefined, playlistId: record.playlistId ?? undefined, @@ -285,10 +312,23 @@ export async function installMainCapture( typeof message['requestId'] === 'string' ) { const request = dbRequests.get(message['requestId']); + if (request) { + record.requestPerformance.push({ + identity: request.identity, + operation: request.operation, + performanceCapture: message['performance'] ?? null, + playlistId: request.playlistId, + requestId: message['requestId'], + responseEpochMs: nowEpochMs(), + success: message['success'] === true, + }); + } if (request) { dbRequests.delete(message['requestId']); recordTimeline({ operation: request.operation, + operationId: + request.identity.operationId ?? undefined, playlistId: request.playlistId ?? undefined, requestId: message['requestId'], success: message['success'] === true, @@ -299,46 +339,6 @@ export async function installMainCapture( }); return record; }; - const readWorkerPerformance = ( - message: JsonRecord - ): { - eventLoopDelay: { - maxMs: number; - p95Ms: number; - p99Ms: number; - }; - } | null => { - const performanceCapture = message['performance']; - if ( - typeof performanceCapture !== 'object' || - performanceCapture === null - ) { - return null; - } - const eventLoopDelay = (performanceCapture as JsonRecord)[ - 'eventLoopDelay' - ]; - if (typeof eventLoopDelay !== 'object' || eventLoopDelay === null) { - return null; - } - const value = eventLoopDelay as JsonRecord; - const maxMs = value['maxMs']; - const p95Ms = value['p95Ms']; - const p99Ms = value['p99Ms']; - if ( - typeof maxMs !== 'number' || - !Number.isFinite(maxMs) || - typeof p95Ms !== 'number' || - !Number.isFinite(p95Ms) || - typeof p99Ms !== 'number' || - !Number.isFinite(p99Ms) - ) { - return null; - } - return { - eventLoopDelay: { maxMs, p95Ms, p99Ms }, - }; - }; const sampleWorker = async (record: WorkerRecord): Promise => { if (record.sampleBusy || record.finalized) { return; @@ -373,14 +373,45 @@ export async function installMainCapture( record.sampleBusy = false; } }; + const resetWorkerForCapture = (record: WorkerRecord): void => { + if (record.sampleTimer) { + clearInterval(record.sampleTimer); + } + record.cancelPostedEpochMs = null; + record.captureGeneration = state.captureGeneration; + record.cpuFirst = null; + record.cpuLast = null; + record.elu = null; + record.eluStart = null; + record.externalPeak = 0; + record.finalized = false; + record.finalizing = null; + record.heapPeak = 0; + record.operationId = null; + record.playlistId = null; + record.postGcHeapUsed = null; + record.profileHandle = null; + record.profilePath = null; + record.requestPerformance = []; + record.responseEpochMs = null; + record.sampleBusy = false; + record.sampleTimer = null; + record.snapshotPath = null; + record.terminatedEpochMs = null; + }; const startWorker = (record: WorkerRecord): void => { - if (!state.active || record.sampleTimer !== null) { + if (!state.active) { + return; + } + if (record.captureGeneration !== state.captureGeneration) { + resetWorkerForCapture(record); + } + if (record.sampleTimer !== null) { return; } record.elu = null; record.eluStart = record.worker.performance?.eventLoopUtilization() ?? null; - record.eventLoopDelay = null; void sampleWorker(record); record.sampleTimer = setInterval( () => void sampleWorker(record), @@ -456,7 +487,11 @@ export async function installMainCapture( const kind = classifyRequest(value); if (kind) { const record = createWorkerRecord(this, kind); - if (kind === 'playlist-refresh.worker') { + startWorker(record); + if ( + isCurrentCaptureRecord(record) && + kind === 'playlist-refresh.worker' + ) { const payload = value['payload'] as JsonRecord; record.operationId = String(payload['operationId']); record.playlistId = String(payload['playlistId']); @@ -467,23 +502,47 @@ export async function installMainCapture( type: 'playlist-request', }); } else if ( + isCurrentCaptureRecord(record) && typeof value['requestId'] === 'string' && typeof value['operation'] === 'string' ) { + const payload = value['payload']; + const payloadOperationId = + typeof payload === 'object' && + payload !== null && + typeof (payload as JsonRecord)[ + 'operationId' + ] === 'string' && + String((payload as JsonRecord)['operationId']) + .length > 0 + ? String( + (payload as JsonRecord)['operationId'] + ) + : null; + const identity = + payloadOperationId === null + ? databaseRequestIdentityCapture.matchDatabaseRequest( + value + ) + : { + operationId: payloadOperationId, + operationIdUnavailableReason: null, + }; dbRequests.set(value['requestId'], { + identity, operation: value['operation'], playlistId: readDatabasePlaylistId(value), record, }); recordTimeline({ operation: value['operation'], + operationId: identity.operationId ?? undefined, playlistId: readDatabasePlaylistId(value) ?? undefined, requestId: value['requestId'], type: 'db-request', }); } - startWorker(record); } } else if ( value['type'] === 'cancel' && @@ -505,7 +564,7 @@ export async function installMainCapture( this: InstrumentedWorker ): Promise { const record = records.get(this); - if (!record || !state.active) { + if (!record || !isCurrentCaptureRecord(record)) { return originalTerminate.call(this); } const markTerminated = (code: number): number => { @@ -627,6 +686,7 @@ export async function installMainCapture( ).length, playlistTerminated: [...records.values()].filter( (record) => + isCurrentCaptureRecord(record) && record.kind === 'playlist-refresh.worker' && record.terminatedEpochMs !== null ).length, @@ -645,7 +705,11 @@ export async function installMainCapture( ).length, }), start: async (options: MainCaptureStartOptions): Promise => { + state.captureGeneration += 1; state.active = true; + databaseRequestIdentityCapture.start(); + dbRequests.clear(); + operationWorkers.clear(); state.diagnostic = options.diagnostic; state.outputDirectory = options.outputDirectory; state.timeline = []; @@ -682,7 +746,7 @@ export async function installMainCapture( ); } }, - stop: async (): Promise => { + stop: async (): Promise => { if (state.sampleTimer) { clearInterval(state.sampleTimer); state.sampleTimer = null; @@ -705,6 +769,7 @@ export async function installMainCapture( const delay = state.eventLoopDelay; const databaseRecords = [...records.values()].filter( (record) => + record.captureGeneration === state.captureGeneration && record.kind === 'database.worker' && record.sampleTimer !== null ); @@ -756,78 +821,91 @@ export async function installMainCapture( } state.windowListeners = []; state.active = false; + databaseRequestIdentityCapture.stop(); const workers = [...records.values()] .filter( (record) => record.kind === 'playlist-refresh.worker' || - record.heapPeak > 0 + record.heapPeak > 0 || + record.requestPerformance.length > 0 ) .map((record) => ({ - cancelPostedEpochMs: record.cancelPostedEpochMs, - cpuSystemMicros: - record.cpuFirst && record.cpuLast - ? record.cpuLast.system - record.cpuFirst.system - : null, - cpuUserMicros: - record.cpuFirst && record.cpuLast - ? record.cpuLast.user - record.cpuFirst.user - : null, - eventLoopDelay: record.eventLoopDelay, - eventLoopDelayUnavailableReason: - record.eventLoopDelay === null - ? record.terminatedEpochMs !== null - ? 'worker-terminated-before-profile-flush' - : 'worker-self-profile-result-unavailable' - : null, - eventLoopUtilization: record.elu, - kind: record.kind, - operationId: record.operationId, - peakExternalBytes: record.externalPeak, - peakHeapUsedBytes: record.heapPeak, - playlistId: record.playlistId, - postGcHeapUsedBytes: record.postGcHeapUsed, - profilePath: record.profilePath, - responseEpochMs: record.responseEpochMs, - snapshotPath: record.snapshotPath, - terminatedEpochMs: record.terminatedEpochMs, + captureGeneration: record.captureGeneration, + metrics: { + cancelPostedEpochMs: record.cancelPostedEpochMs, + cpuSystemMicros: + record.cpuFirst && record.cpuLast + ? record.cpuLast.system - + record.cpuFirst.system + : null, + cpuUserMicros: + record.cpuFirst && record.cpuLast + ? record.cpuLast.user - record.cpuFirst.user + : null, + eventLoopUtilization: record.elu, + kind: record.kind, + operationId: record.operationId, + peakExternalBytes: record.externalPeak, + peakHeapUsedBytes: record.heapPeak, + playlistId: record.playlistId, + postGcHeapUsedBytes: record.postGcHeapUsed, + profilePath: record.profilePath, + responseEpochMs: record.responseEpochMs, + snapshotPath: record.snapshotPath, + terminatedEpochMs: record.terminatedEpochMs, + }, + requests: record.requestPerformance.map((request) => ({ + operation: request.operation, + operationId: request.identity.operationId, + operationIdUnavailableReason: + request.identity.operationIdUnavailableReason, + performanceCapture: request.performanceCapture, + playlistId: request.playlistId, + requestId: request.requestId, + responseEpochMs: request.responseEpochMs, + success: request.success, + })), })); return { - cpuProfilePath: state.mainProfilePath, - cpuSystemMicros: cpu.system, - cpuUserMicros: cpu.user, - eventLoopDelay: { - maxMs: Number(delay?.max ?? 0) / 1e6, - p95Ms: Number(delay?.percentile(95) ?? 0) / 1e6, - p99Ms: Number(delay?.percentile(99) ?? 0) / 1e6, + captureGeneration: state.captureGeneration, + metrics: { + cpuProfilePath: state.mainProfilePath, + cpuSystemMicros: cpu.system, + cpuUserMicros: cpu.user, + eventLoopDelay: { + maxMs: Number(delay?.max ?? 0) / 1e6, + p95Ms: Number(delay?.percentile(95) ?? 0) / 1e6, + p99Ms: Number(delay?.percentile(99) ?? 0) / 1e6, + }, + // Electron's Chromium-owned main message pump currently + // leaves Node's libuv ELU counters at zero. Preserve that + // as unavailable instead of reporting a misleading 0%. + eventLoopUtilization, + eventLoopUtilizationUnavailableReason: + eventLoopUtilization === null + ? 'electron-main-embedded-event-loop' + : null, + heapSnapshotPath: state.mainSnapshotPath, + memory: { + peakHeapUsedBytes: state.mainPeakHeap, + peakRssBytes: state.mainPeakRss, + postGcHeapUsedBytes: state.postGcHeap, + postGcRssBytes: state.postGcRss, + }, + rendererPeakRssBytes: state.rendererPeakRss, + responsiveEvents: state.responsiveEvents, + rssScope: + 'electron-main-process-including-worker-threads-and-native-memory', + timeline: state.timeline, + unresponsiveEvents: state.unresponsiveEvents, }, - // Electron's Chromium-owned main message pump currently - // leaves Node's libuv ELU counters at zero. Preserve that - // as unavailable instead of reporting a misleading 0%. - eventLoopUtilization, - eventLoopUtilizationUnavailableReason: - eventLoopUtilization === null - ? 'electron-main-embedded-event-loop' - : null, - heapSnapshotPath: state.mainSnapshotPath, - memory: { - peakHeapUsedBytes: state.mainPeakHeap, - peakRssBytes: state.mainPeakRss, - postGcHeapUsedBytes: state.postGcHeap, - postGcRssBytes: state.postGcRss, - }, - rendererPeakRssBytes: state.rendererPeakRss, - responsiveEvents: state.responsiveEvents, - rssScope: - 'electron-main-process-including-worker-threads-and-native-memory', - timeline: state.timeline, - unresponsiveEvents: state.unresponsiveEvents, workers, }; }, }; - target[stateKey] = api; - }, MAIN_CAPTURE_STATE_KEY); + target[input.stateKey] = api; + }, captureStateKeys); } export async function startMainCapture( @@ -861,11 +939,15 @@ export async function readMainCaptureStatus( export async function stopMainCapture( electronApp: ElectronApplication ): Promise { - return electronApp.evaluate(async (_electron, stateKey) => { - const target = globalThis as unknown as Record; - const api = target[stateKey] as { - stop(): Promise; - }; - return api.stop(); - }, MAIN_CAPTURE_STATE_KEY); + const transport = await electronApp.evaluate( + async (_electron, stateKey) => { + const target = globalThis as unknown as Record; + const api = target[stateKey] as { + stop(): Promise; + }; + return api.stop(); + }, + MAIN_CAPTURE_STATE_KEY + ); + return selectMainCaptureGeneration(transport); } diff --git a/apps/electron-backend-e2e/src/performance/worker-request-performance-envelope.spec.ts b/apps/electron-backend-e2e/src/performance/worker-request-performance-envelope.spec.ts new file mode 100644 index 000000000..e87e3c345 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/worker-request-performance-envelope.spec.ts @@ -0,0 +1,172 @@ +/* eslint-disable playwright/expect-expect -- These are Node assertion-based performance contract tests. */ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { WorkerRequestPerformanceMetrics } from './m3u-refresh-cancellation-contract'; +import { normalizeWorkerRequestPerformanceOutcome } from './worker-request-performance'; + +const validCapture = { + eventLoopDelay: { maxMs: 6, p95Ms: 5, p99Ms: 5.5 }, + eventLoopDelayUnavailableReason: null, + eventLoopUtilization: 0.5, + eventLoopUtilizationUnavailableReason: null, + histogramFlushedEpochMs: 1_050, + invalidReason: null, + requestReceivedEpochMs: 1_000, + threadCpuSystemMicros: 50, + threadCpuUnavailableReason: null, + threadCpuUserMicros: 100, + workEndedEpochMs: 1_040, + workStartedEpochMs: 1_010, +} as const; + +function normalize(performanceCapture: unknown) { + return normalizeWorkerRequestPerformanceOutcome({ + operation: 'DB_GET_APP_PLAYLIST', + operationId: 'operation-42', + operationIdUnavailableReason: null, + performanceCapture, + playlistId: 'playlist-1', + requestId: 'request-1', + responseEpochMs: 1_060, + success: true, + }); +} + +function assertClosedCapture( + capture: WorkerRequestPerformanceMetrics, + reason: string +): void { + assert.equal(capture.performanceCaptureUnavailableReason, reason); + assert.equal(capture.operationId, 'operation-42'); + for (const value of [ + capture.eventLoopDelay, + capture.eventLoopDelayUnavailableReason, + capture.eventLoopUtilization, + capture.eventLoopUtilizationUnavailableReason, + capture.histogramFlushedEpochMs, + capture.invalidReason, + capture.requestReceivedEpochMs, + capture.threadCpuSystemMicros, + capture.threadCpuUnavailableReason, + capture.threadCpuUserMicros, + capture.workEndedEpochMs, + capture.workStartedEpochMs, + ]) { + assert.equal(value, null); + } +} + +test('absent captures are missing while present non-record captures are invalid', () => { + for (const missing of [undefined, null]) { + assertClosedCapture( + normalize(missing), + 'worker-performance-capture-missing' + ); + } + for (const malformed of [42, [], 'capture']) { + assertClosedCapture( + normalize(malformed), + 'worker-performance-capture-invalid' + ); + } +}); + +test('malformed nested fields and cross-field invariants fail the whole capture closed', () => { + const malformed = [ + { ...validCapture, eventLoopDelay: [] }, + { + ...validCapture, + eventLoopDelay: { maxMs: 6, p95Ms: 'bad', p99Ms: 5.5 }, + }, + { + ...validCapture, + eventLoopDelay: { maxMs: 5, p95Ms: 6, p99Ms: 5.5 }, + }, + { ...validCapture, eventLoopDelayUnavailableReason: 42 }, + { + ...validCapture, + eventLoopDelay: null, + eventLoopDelayUnavailableReason: null, + histogramFlushedEpochMs: null, + }, + { + ...validCapture, + eventLoopDelay: null, + eventLoopDelayUnavailableReason: + 'event-loop-delay-capture-unavailable', + }, + { ...validCapture, eventLoopUtilization: 1.01 }, + { + ...validCapture, + eventLoopUtilization: null, + eventLoopUtilizationUnavailableReason: null, + }, + { ...validCapture, threadCpuSystemMicros: null }, + { + ...validCapture, + threadCpuSystemMicros: null, + threadCpuUnavailableReason: null, + threadCpuUserMicros: null, + }, + { ...validCapture, threadCpuUnavailableReason: 'unavailable' }, + { ...validCapture, histogramFlushedEpochMs: null }, + { + ...validCapture, + eventLoopDelay: null, + eventLoopDelayUnavailableReason: 'event-loop-delay-invalid', + histogramFlushedEpochMs: null, + }, + { ...validCapture, histogramFlushedEpochMs: 1_039 }, + { ...validCapture, workStartedEpochMs: 999 }, + { + ...validCapture, + invalidReason: 'overlapping-database-worker-requests', + }, + { ...validCapture, threadCpuUserMicros: undefined }, + ]; + for (const performanceCapture of malformed) { + assertClosedCapture( + normalize(performanceCapture), + 'worker-performance-capture-invalid' + ); + } +}); + +test('coherent metric-unavailable and invalid worker results remain valid raw captures', () => { + const unavailable = { + ...validCapture, + eventLoopDelay: null, + eventLoopDelayUnavailableReason: 'event-loop-delay-capture-unavailable', + eventLoopUtilization: null, + eventLoopUtilizationUnavailableReason: + 'event-loop-utilization-unavailable', + histogramFlushedEpochMs: null, + threadCpuSystemMicros: null, + threadCpuUnavailableReason: 'thread-cpu-usage-unavailable', + threadCpuUserMicros: null, + }; + for (const capture of [ + unavailable, + { + ...validCapture, + eventLoopDelay: null, + eventLoopDelayUnavailableReason: 'event-loop-delay-invalid', + }, + ]) { + const accepted = normalize(capture); + assert.equal(accepted.performanceCaptureUnavailableReason, null); + assert.equal(accepted.requestReceivedEpochMs, 1_000); + } + + const invalidReason = 'overlapping-database-worker-requests'; + const legitimateInvalid = normalize({ + ...unavailable, + eventLoopDelayUnavailableReason: invalidReason, + eventLoopUtilizationUnavailableReason: invalidReason, + invalidReason, + threadCpuUnavailableReason: invalidReason, + }); + assert.equal(legitimateInvalid.performanceCaptureUnavailableReason, null); + assert.equal(legitimateInvalid.invalidReason, invalidReason); +}); diff --git a/apps/electron-backend-e2e/src/performance/worker-request-performance.spec.ts b/apps/electron-backend-e2e/src/performance/worker-request-performance.spec.ts new file mode 100644 index 000000000..72a3ae522 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/worker-request-performance.spec.ts @@ -0,0 +1,302 @@ +/* eslint-disable playwright/expect-expect -- These are Node assertion-based performance contract tests. */ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +import { + type CancellationBenchmarkManifest, + type CancellationIterationResult, + PERFORMANCE_ITERATION_KIND, + PERFORMANCE_WORKER_KIND, + type WorkerCaptureMetrics, + type WorkerRequestPerformanceMetrics, +} from './m3u-refresh-cancellation-contract'; +import { + normalizeWorkerRequestPerformanceOutcome, + selectMainCaptureGeneration, +} from './worker-request-performance'; +import { createCancellationBenchmarkSummary } from './m3u-refresh-cancellation-report'; + +type RequestPerformanceFixture = WorkerRequestPerformanceMetrics; +function requestPerformance( + requestId: string, + p95Ms: number, + threadCpuUserMicros: number +): RequestPerformanceFixture { + return normalizeWorkerRequestPerformanceOutcome( + requestTransport( + { + eventLoopDelay: { + maxMs: p95Ms + 1, + p95Ms, + p99Ms: p95Ms + 0.5, + }, + eventLoopDelayUnavailableReason: null, + eventLoopUtilization: p95Ms / 100, + eventLoopUtilizationUnavailableReason: null, + histogramFlushedEpochMs: 1_050 + p95Ms, + invalidReason: null, + requestReceivedEpochMs: 1_000, + threadCpuSystemMicros: threadCpuUserMicros / 2, + threadCpuUnavailableReason: null, + threadCpuUserMicros, + workEndedEpochMs: 1_040, + workStartedEpochMs: 1_010, + }, + requestId + ) + ); +} +function requestTransport( + performanceCapture: unknown, + requestId = 'request-1' +) { + return { + operation: 'DB_GET_APP_PLAYLIST', + operationId: 'operation-42', + operationIdUnavailableReason: null, + performanceCapture, + playlistId: 'playlist-1', + requestId, + responseEpochMs: 1_060, + success: true, + } as const; +} +function assertClosedCapture( + capture: WorkerRequestPerformanceMetrics, + reason: string +): void { + assert.equal(capture.performanceCaptureUnavailableReason, reason); + assert.equal(capture.operationId, 'operation-42'); + for (const value of [ + capture.eventLoopDelay, + capture.eventLoopDelayUnavailableReason, + capture.eventLoopUtilization, + capture.eventLoopUtilizationUnavailableReason, + capture.histogramFlushedEpochMs, + capture.invalidReason, + capture.requestReceivedEpochMs, + capture.threadCpuSystemMicros, + capture.threadCpuUnavailableReason, + capture.threadCpuUserMicros, + capture.workEndedEpochMs, + capture.workStartedEpochMs, + ]) { + assert.equal(value, null); + } +} +function databaseWorker( + requests: readonly RequestPerformanceFixture[] +): WorkerCaptureMetrics { + return { + kind: PERFORMANCE_WORKER_KIND.DATABASE, + operationId: null, + peakExternalBytes: 0, + peakHeapUsedBytes: 0, + playlistId: null, + postGcHeapUsedBytes: null, + requests, + responseEpochMs: null, + terminatedEpochMs: null, + } as WorkerCaptureMetrics; +} +function playlistWorker( + request: RequestPerformanceFixture, + operationId: string, + terminatedEpochMs: number +): WorkerCaptureMetrics { + return { + ...databaseWorker([request]), + kind: PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, + operationId, + playlistId: request.playlistId, + responseEpochMs: request.responseEpochMs, + terminatedEpochMs, + }; +} + +function measuredIteration( + workers: readonly WorkerCaptureMetrics[] +): CancellationIterationResult { + return { + cancellationEffectObserved: true, + kind: PERFORMANCE_ITERATION_KIND.MEASURED, + main: { + eventLoopDelay: { maxMs: 0, p95Ms: 0, p99Ms: 0 }, + eventLoopUtilization: null, + memory: { + peakHeapUsedBytes: 0, + peakRssBytes: 0, + postGcHeapUsedBytes: null, + postGcRssBytes: null, + }, + rendererPeakRssBytes: 0, + responsiveEvents: 0, + unresponsiveEvents: 0, + workers, + } as CancellationIterationResult['main'], + phases: {} as CancellationIterationResult['phases'], + renderer: { + peakHeapUsedBytes: 0, + postGcHeapUsedBytes: null, + probe: { + frameGapsMs: [], + heartbeatDelaysMs: [], + longTasksMs: [], + }, + } as CancellationIterationResult['renderer'], + runId: 'measured-1', + }; +} + +test('summary distributions use every request capture instead of a worker-level percentile maximum', () => { + const requests = [ + requestPerformance('request-1', 5, 100), + requestPerformance('request-2', 15, 300), + ]; + const summary = createCancellationBenchmarkSummary( + {} as CancellationBenchmarkManifest, + [measuredIteration([databaseWorker(requests)])] + ); + const measured = summary.measured as unknown as Record< + string, + { count: number; max: number | null; min: number | null } + >; + + assert.deepEqual( + summary.measured.databaseWorkerRequestEventLoopDelayP95Ms, + { + count: 2, + max: 15, + mean: 10, + median: 10, + min: 5, + p95: 14.5, + p99: 14.9, + } + ); + assert.deepEqual(measured['databaseWorkerRequestThreadCpuUserMicros'], { + count: 2, + max: 300, + mean: 200, + median: 200, + min: 100, + p95: 290, + p99: 298, + }); + assert.equal( + summary.iterations[0]?.main.workers[0]?.requests[0]?.operationId, + 'operation-42' + ); + assert.equal( + 'databaseWorkerEventLoopDelayP95Ms' in summary.measured, + false, + 'request-percentile distributions must not look like an operation-wide percentile' + ); +}); + +test('main capture retains raw request identity, timestamps, metrics, and reasons', () => { + const source = readFileSync( + new URL('./m3u-refresh-main-capture.ts', import.meta.url), + 'utf8' + ); + + assert.match(source, /requestPerformance: \[\]/); + assert.match(source, /record\.requestPerformance\.length > 0/); + assert.match(source, /record\.requestPerformance\.map/); + assert.match(source, /operationId: request\.identity\.operationId/); + assert.match(source, /identity: request\.identity/); + assert.match(source, /performanceCapture:\s+message\['performance'\]/); + assert.match(source, /captureGeneration: state\.captureGeneration/); + assert.doesNotMatch( + source, + /record\.eventLoopDelay = record\.eventLoopDelay/ + ); +}); + +test('capture-generation selection excludes a pre-start seed worker', () => { + const seed = playlistWorker( + requestPerformance('seed-request', 999, 999), + 'seed-operation', + 1_100 + ); + const measured = playlistWorker( + { + ...requestPerformance('measured-request', 8, 80), + success: false, + }, + 'measured-cancel-operation', + 2_100 + ); + + const mainMetrics = measuredIteration([]).main; + const toTransportWorker = ( + captureGeneration: number | null, + worker: WorkerCaptureMetrics + ) => ({ + captureGeneration, + metrics: worker, + requests: worker.requests.map((request) => ({ + ...requestTransport(request, request.requestId ?? undefined), + success: request.success, + })), + }); + const selected = selectMainCaptureGeneration({ + captureGeneration: 2, + metrics: mainMetrics, + workers: [ + toTransportWorker(null, seed), + toTransportWorker(2, measured), + ], + }); + + assert.equal(selected.workers.length, 1); + assert.equal( + selected.workers[0]?.requests[0]?.requestId, + 'measured-request' + ); + assert.equal(selected.workers[0]?.operationId, 'measured-cancel-operation'); + assert.equal(selected.workers[0]?.terminatedEpochMs, 2_100); +}); + +test('raw outcomes retain missing and malformed captures while summaries exclude their null metrics', () => { + const valid = normalizeWorkerRequestPerformanceOutcome( + requestTransport(requestPerformance('request-1', 5, 100)) + ); + const missing = normalizeWorkerRequestPerformanceOutcome( + requestTransport(null, 'request-2') + ); + const malformed = normalizeWorkerRequestPerformanceOutcome( + requestTransport( + { + requestReceivedEpochMs: 'invalid', + workEndedEpochMs: 1_080, + workStartedEpochMs: 1_075, + }, + 'request-3' + ) + ); + const worker = databaseWorker([valid, missing, malformed]); + const summary = createCancellationBenchmarkSummary( + {} as CancellationBenchmarkManifest, + [measuredIteration([worker])] + ); + + assert.equal(worker.requests.length, 3); + assertClosedCapture( + worker.requests[1] as WorkerRequestPerformanceMetrics, + 'worker-performance-capture-missing' + ); + assertClosedCapture( + worker.requests[2] as WorkerRequestPerformanceMetrics, + 'worker-performance-capture-invalid' + ); + assert.equal( + summary.measured.databaseWorkerRequestEventLoopDelayP95Ms.count, + 1 + ); + assert.equal( + summary.measured.databaseWorkerRequestThreadCpuUserMicros.count, + 1 + ); +}); diff --git a/apps/electron-backend-e2e/src/performance/worker-request-performance.ts b/apps/electron-backend-e2e/src/performance/worker-request-performance.ts new file mode 100644 index 000000000..edf157282 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/worker-request-performance.ts @@ -0,0 +1,302 @@ +import type { + EventLoopDelayMetrics, + MainCaptureMetrics, + WorkerCaptureMetrics, + WorkerRequestPerformanceMetrics, +} from './m3u-refresh-cancellation-contract'; + +export interface WorkerRequestPerformanceOutcomeTransport { + readonly operation: string | null; + readonly operationId: string | null; + readonly operationIdUnavailableReason: string | null; + readonly performanceCapture: unknown; + readonly playlistId: string | null; + readonly requestId: string | null; + readonly responseEpochMs: number; + readonly success: boolean; +} + +export interface MainCaptureGenerationTransport { + readonly captureGeneration: number; + readonly metrics: Omit; + readonly workers: readonly { + readonly captureGeneration: number | null; + readonly metrics: Omit< + WorkerCaptureMetrics, + 'eventLoopDelay' | 'eventLoopDelayUnavailableReason' | 'requests' + >; + readonly requests: readonly WorkerRequestPerformanceOutcomeTransport[]; + }[]; +} + +type ParsedWorkerPerformanceCapture = Pick< + WorkerRequestPerformanceMetrics, + | 'eventLoopDelay' + | 'eventLoopDelayUnavailableReason' + | 'eventLoopUtilization' + | 'eventLoopUtilizationUnavailableReason' + | 'histogramFlushedEpochMs' + | 'invalidReason' + | 'requestReceivedEpochMs' + | 'threadCpuSystemMicros' + | 'threadCpuUnavailableReason' + | 'threadCpuUserMicros' + | 'workEndedEpochMs' + | 'workStartedEpochMs' +>; + +const INVALID_REASONS = new Set(['overlapping-database-worker-requests']); +const EVENT_LOOP_DELAY_REASONS = new Set([ + ...INVALID_REASONS, + 'event-loop-delay-arm-timeout', + 'event-loop-delay-capture-unavailable', + 'event-loop-delay-flush-timeout', + 'event-loop-delay-invalid', +]); +const EVENT_LOOP_UTILIZATION_REASONS = new Set([ + ...INVALID_REASONS, + 'event-loop-utilization-unavailable', +]); +const THREAD_CPU_REASONS = new Set([ + ...INVALID_REASONS, + 'thread-cpu-usage-invalid', + 'thread-cpu-usage-unavailable', +]); + +function isRecord(input: unknown): input is Record { + return typeof input === 'object' && input !== null && !Array.isArray(input); +} + +function isFiniteNonNegativeNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0; +} + +function isNullableFiniteNonNegativeNumber( + value: unknown +): value is number | null { + return value === null || isFiniteNonNegativeNumber(value); +} + +function isNullableReason( + value: unknown, + allowed: ReadonlySet +): value is string | null { + return value === null || (typeof value === 'string' && allowed.has(value)); +} + +function parseEventLoopDelay( + input: unknown +): EventLoopDelayMetrics | null | undefined { + if (input === null) { + return null; + } + if (!isRecord(input)) { + return undefined; + } + const maxMs = input['maxMs']; + const p95Ms = input['p95Ms']; + const p99Ms = input['p99Ms']; + if ( + !isFiniteNonNegativeNumber(maxMs) || + !isFiniteNonNegativeNumber(p95Ms) || + !isFiniteNonNegativeNumber(p99Ms) || + p95Ms > p99Ms || + p99Ms > maxMs + ) { + return undefined; + } + return { maxMs, p95Ms, p99Ms }; +} + +function parseWorkerPerformanceCapture( + input: unknown +): ParsedWorkerPerformanceCapture | null { + if (!isRecord(input)) { + return null; + } + + const eventLoopDelay = parseEventLoopDelay(input['eventLoopDelay']); + const eventLoopDelayUnavailableReason = + input['eventLoopDelayUnavailableReason']; + const eventLoopUtilization = input['eventLoopUtilization']; + const eventLoopUtilizationUnavailableReason = + input['eventLoopUtilizationUnavailableReason']; + const histogramFlushedEpochMs = input['histogramFlushedEpochMs']; + const invalidReason = input['invalidReason']; + const requestReceivedEpochMs = input['requestReceivedEpochMs']; + const threadCpuSystemMicros = input['threadCpuSystemMicros']; + const threadCpuUnavailableReason = input['threadCpuUnavailableReason']; + const threadCpuUserMicros = input['threadCpuUserMicros']; + const workEndedEpochMs = input['workEndedEpochMs']; + const workStartedEpochMs = input['workStartedEpochMs']; + + if ( + eventLoopDelay === undefined || + !isNullableReason( + eventLoopDelayUnavailableReason, + EVENT_LOOP_DELAY_REASONS + ) || + !isNullableFiniteNonNegativeNumber(eventLoopUtilization) || + (eventLoopUtilization !== null && eventLoopUtilization > 1) || + !isNullableReason( + eventLoopUtilizationUnavailableReason, + EVENT_LOOP_UTILIZATION_REASONS + ) || + !isNullableFiniteNonNegativeNumber(histogramFlushedEpochMs) || + !isNullableReason(invalidReason, INVALID_REASONS) || + !isFiniteNonNegativeNumber(requestReceivedEpochMs) || + !isNullableFiniteNonNegativeNumber(threadCpuSystemMicros) || + !isNullableReason(threadCpuUnavailableReason, THREAD_CPU_REASONS) || + !isNullableFiniteNonNegativeNumber(threadCpuUserMicros) || + !isFiniteNonNegativeNumber(workEndedEpochMs) || + !isFiniteNonNegativeNumber(workStartedEpochMs) + ) { + return null; + } + + const timestampsAreOrdered = + requestReceivedEpochMs <= workStartedEpochMs && + workStartedEpochMs <= workEndedEpochMs && + (histogramFlushedEpochMs === null || + workEndedEpochMs <= histogramFlushedEpochMs); + const eventLoopDelayIsCoherent = + eventLoopDelay === null + ? eventLoopDelayUnavailableReason !== null && + (eventLoopDelayUnavailableReason === 'event-loop-delay-invalid' + ? histogramFlushedEpochMs !== null + : histogramFlushedEpochMs === null) + : eventLoopDelayUnavailableReason === null && + histogramFlushedEpochMs !== null; + const eventLoopUtilizationIsCoherent = + eventLoopUtilization === null + ? eventLoopUtilizationUnavailableReason !== null + : eventLoopUtilizationUnavailableReason === null; + const threadCpuIsCoherent = + threadCpuSystemMicros === null && threadCpuUserMicros === null + ? threadCpuUnavailableReason !== null + : threadCpuSystemMicros !== null && + threadCpuUserMicros !== null && + threadCpuUnavailableReason === null; + const invalidCaptureIsCoherent = + invalidReason === null || + (eventLoopDelay === null && + eventLoopDelayUnavailableReason === invalidReason && + eventLoopUtilization === null && + eventLoopUtilizationUnavailableReason === invalidReason && + histogramFlushedEpochMs === null && + threadCpuSystemMicros === null && + threadCpuUnavailableReason === invalidReason && + threadCpuUserMicros === null); + if ( + !timestampsAreOrdered || + !eventLoopDelayIsCoherent || + !eventLoopUtilizationIsCoherent || + !threadCpuIsCoherent || + !invalidCaptureIsCoherent + ) { + return null; + } + + return { + eventLoopDelay, + eventLoopDelayUnavailableReason, + eventLoopUtilization, + eventLoopUtilizationUnavailableReason, + histogramFlushedEpochMs, + invalidReason, + requestReceivedEpochMs, + threadCpuSystemMicros, + threadCpuUnavailableReason, + threadCpuUserMicros, + workEndedEpochMs, + workStartedEpochMs, + }; +} + +export function normalizeWorkerRequestPerformanceOutcome( + input: WorkerRequestPerformanceOutcomeTransport +): WorkerRequestPerformanceMetrics { + const identity = { + operation: input.operation, + operationId: input.operationId, + operationIdUnavailableReason: input.operationIdUnavailableReason, + playlistId: input.playlistId, + requestId: input.requestId, + responseEpochMs: input.responseEpochMs, + success: input.success, + }; + const unavailable = ( + reason: + | 'worker-performance-capture-invalid' + | 'worker-performance-capture-missing' + ): WorkerRequestPerformanceMetrics => ({ + eventLoopDelay: null, + eventLoopDelayUnavailableReason: null, + eventLoopUtilization: null, + eventLoopUtilizationUnavailableReason: null, + histogramFlushedEpochMs: null, + invalidReason: null, + ...identity, + performanceCaptureUnavailableReason: reason, + requestReceivedEpochMs: null, + threadCpuSystemMicros: null, + threadCpuUnavailableReason: null, + threadCpuUserMicros: null, + workEndedEpochMs: null, + workStartedEpochMs: null, + }); + + if ( + input.performanceCapture === undefined || + input.performanceCapture === null + ) { + return unavailable('worker-performance-capture-missing'); + } + + const performanceCapture = parseWorkerPerformanceCapture( + input.performanceCapture + ); + if (!performanceCapture) { + return unavailable('worker-performance-capture-invalid'); + } + + return { + ...performanceCapture, + ...identity, + performanceCaptureUnavailableReason: null, + }; +} + +export function selectMainCaptureGeneration( + input: MainCaptureGenerationTransport +): MainCaptureMetrics { + const workers = input.workers + .filter( + (worker) => worker.captureGeneration === input.captureGeneration + ) + .map((worker): WorkerCaptureMetrics => { + const requests = worker.requests.map( + normalizeWorkerRequestPerformanceOutcome + ); + const onlyRequest = + requests.length === 1 ? (requests[0] ?? null) : null; + return { + ...worker.metrics, + eventLoopDelay: onlyRequest?.eventLoopDelay ?? null, + eventLoopDelayUnavailableReason: onlyRequest + ? (onlyRequest.eventLoopDelayUnavailableReason ?? + onlyRequest.performanceCaptureUnavailableReason ?? + 'worker-self-profile-result-unavailable') + : requests.length > 1 + ? 'multiple-request-scoped-captures' + : worker.metrics.terminatedEpochMs !== null + ? 'worker-terminated-before-profile-flush' + : 'worker-self-profile-result-unavailable', + requests, + }; + }); + return { + ...input.metrics, + workers, + }; +} diff --git a/apps/electron-backend/src/app/workers/database.worker.ts b/apps/electron-backend/src/app/workers/database.worker.ts index b3b4d968a..2310d3398 100644 --- a/apps/electron-backend/src/app/workers/database.worker.ts +++ b/apps/electron-backend/src/app/workers/database.worker.ts @@ -90,8 +90,11 @@ import { } from '../database/operations/xtream.operations'; import { armWorkerPerformanceCapture, - finishWorkerPerformanceCapture, + executeWithWorkerPerformanceCapture, + registerDatabaseWorkerPerformanceCapture, + releaseDatabaseWorkerPerformanceCapture, startWorkerPerformanceCapture, + type WorkerPerformanceCapture, } from './worker-performance-capture'; const loggerLabel = '[DB Worker]'; @@ -104,6 +107,11 @@ type ActiveOperationState = { cancelled: boolean; }; +type PreRegisteredActiveOperation = { + operationId: string; + state: ActiveOperationState; +}; + type OperationController = { control: { checkpoint: () => Promise; @@ -129,6 +137,7 @@ type OperationController = { }; const activeOperations = new Map(); +const activePerformanceCaptures = new Set(); if (!parentPort) { throw new Error('Database worker must be started with a parent port'); @@ -178,17 +187,22 @@ async function pauseBetweenBatches(): Promise { await new Promise((resolve) => setTimeout(resolve, batchDelayMs)); } -function createOperationController(config: { - operationId?: string; - operation: string; - playlistId?: string; - requestId: string; - cancellable?: boolean; -}): OperationController { +function createOperationController( + config: { + operationId?: string; + operation: string; + playlistId?: string; + requestId: string; + cancellable?: boolean; + }, + preRegisteredState?: ActiveOperationState +): OperationController { const { operationId, operation, playlistId, requestId } = config; const cancellable = config.cancellable ?? true; const activeState = - operationId && cancellable ? { cancelled: false } : null; + operationId && cancellable + ? (preRegisteredState ?? { cancelled: false }) + : null; if (operationId && activeState) { activeOperations.set(operationId, activeState); @@ -255,7 +269,10 @@ function createOperationController(config: { }); }, cleanup: () => { - if (operationId) { + if ( + operationId && + activeOperations.get(operationId) === activeState + ) { activeOperations.delete(operationId); } }, @@ -264,9 +281,10 @@ function createOperationController(config: { async function executeTrackedOperation( config: Parameters[0], - handler: (controller: OperationController) => Promise + handler: (controller: OperationController) => Promise, + preRegisteredState?: ActiveOperationState ): Promise { - const controller = createOperationController(config); + const controller = createOperationController(config, preRegisteredState); try { return await handler(controller); @@ -282,7 +300,53 @@ async function executeTrackedOperation( } } -async function executeRequest(message: DbWorkerRequestMessage) { +function preRegisterCancellableOperation( + message: DbWorkerRequestMessage +): PreRegisteredActiveOperation | null { + switch (message.operation) { + case 'DB_SAVE_CONTENT': + case 'DB_DELETE_PLAYLIST': + case 'DB_DELETE_XTREAM_CONTENT': + case 'DB_RESTORE_XTREAM_USER_DATA': + break; + default: + return null; + } + + if ( + typeof message.payload !== 'object' || + message.payload === null || + Array.isArray(message.payload) + ) { + return null; + } + const operationId = (message.payload as Record)[ + 'operationId' + ]; + if (typeof operationId !== 'string' || operationId.length === 0) { + return null; + } + + const state: ActiveOperationState = { cancelled: false }; + activeOperations.set(operationId, state); + return { operationId, state }; +} + +function releasePreRegisteredOperation( + operation: PreRegisteredActiveOperation | null +): void { + if ( + operation && + activeOperations.get(operation.operationId) === operation.state + ) { + activeOperations.delete(operation.operationId); + } +} + +async function executeRequest( + message: DbWorkerRequestMessage, + preRegisteredState?: ActiveOperationState +) { const db = await getWorkerDatabase(); switch (message.operation) { @@ -413,7 +477,8 @@ async function executeRequest(message: DbWorkerRequestMessage) { }); return result; - } + }, + preRegisteredState ); } @@ -586,7 +651,8 @@ async function executeRequest(message: DbWorkerRequestMessage) { }); return result; - } + }, + preRegisteredState ); } @@ -695,7 +761,8 @@ async function executeRequest(message: DbWorkerRequestMessage) { }); return result; - } + }, + preRegisteredState ); } @@ -739,7 +806,8 @@ async function executeRequest(message: DbWorkerRequestMessage) { }); return result; - } + }, + preRegisteredState ); } @@ -929,33 +997,49 @@ parentPort.on('message', async (message: DbWorkerIncomingMessage) => { return; } - const performanceCapture = startWorkerPerformanceCapture(); - await armWorkerPerformanceCapture(performanceCapture); - try { - const result = await executeRequest(message); - const performance = - await finishWorkerPerformanceCapture(performanceCapture); + const preRegisteredOperation = preRegisterCancellableOperation(message); + let performanceCapture: WorkerPerformanceCapture | null = null; + const execution = await (async () => { + try { + performanceCapture = startWorkerPerformanceCapture(); + registerDatabaseWorkerPerformanceCapture( + activePerformanceCaptures, + performanceCapture + ); + await armWorkerPerformanceCapture(performanceCapture); + return await executeWithWorkerPerformanceCapture( + performanceCapture, + () => executeRequest(message, preRegisteredOperation?.state) + ); + } finally { + releaseDatabaseWorkerPerformanceCapture( + activePerformanceCaptures, + performanceCapture + ); + releasePreRegisteredOperation(preRegisteredOperation); + } + })(); + + if (execution.success) { postMessage({ type: 'response', requestId: message.requestId, success: true, - result, - performance, + result: execution.result, + performance: execution.performance, }); - } catch (error) { + } else { console.error( loggerLabel, `Error handling ${message.operation}:`, - error + execution.error ); - const performance = - await finishWorkerPerformanceCapture(performanceCapture); postMessage({ type: 'response', requestId: message.requestId, success: false, - error: serializeError(error), - performance, + error: serializeError(execution.error), + performance: execution.performance, }); } }); diff --git a/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts b/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts index 790d6ac55..3bd0059ba 100644 --- a/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts +++ b/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts @@ -24,7 +24,7 @@ import { requestWithValidatedRedirects } from '../util/validated-axios'; import { PLAYLIST_FETCH_TIMEOUT_MS } from '../events/playlist-source'; import { armWorkerPerformanceCapture, - finishWorkerPerformanceCapture, + executeWithWorkerPerformanceCapture, startWorkerPerformanceCapture, } from './worker-performance-capture'; @@ -86,6 +86,15 @@ function checkpoint(payload: PlaylistRefreshPayload): void { } } +function releaseActiveRefresh( + operationId: string, + activeRefresh: ActiveRefreshState +): void { + if (activeRefreshes.get(operationId) === activeRefresh) { + activeRefreshes.delete(operationId); + } +} + async function fetchPlaylistFromUrl( payload: PlaylistRefreshPayload, controller: AbortController @@ -159,23 +168,18 @@ async function fetchPlaylistFromFile( } async function executeRefresh( - payload: PlaylistRefreshPayload + payload: PlaylistRefreshPayload, + activeRefresh: ActiveRefreshState ): Promise { - const controller = new AbortController(); - activeRefreshes.set(payload.operationId, { - cancelled: false, - controller, - }); - try { const playlist = payload.url - ? await fetchPlaylistFromUrl(payload, controller) + ? await fetchPlaylistFromUrl(payload, activeRefresh.controller) : await fetchPlaylistFromFile(payload); checkpoint(payload); return playlist; } finally { - activeRefreshes.delete(payload.operationId); + releaseActiveRefresh(payload.operationId, activeRefresh); } } @@ -191,39 +195,55 @@ parentPort.on( return; } - const performanceCapture = startWorkerPerformanceCapture(); - await armWorkerPerformanceCapture(performanceCapture); + const payload = message.payload; + const activeRefresh: ActiveRefreshState = { + cancelled: false, + controller: new AbortController(), + }; + activeRefreshes.set(payload.operationId, activeRefresh); + try { - const result = await executeRefresh(message.payload); - const performance = - await finishWorkerPerformanceCapture(performanceCapture); - postMessage({ - type: 'response', - success: true, - result, - performance, - }); - } catch (error) { - const payload = message.payload; - if (error instanceof Error && error.name === 'AbortError') { - emitEvent(payload, { status: 'cancelled', phase: 'parsing' }); + const performanceCapture = startWorkerPerformanceCapture(); + await armWorkerPerformanceCapture(performanceCapture); + const execution = await executeWithWorkerPerformanceCapture( + performanceCapture, + () => executeRefresh(payload, activeRefresh) + ); + + if (execution.success) { + postMessage({ + type: 'response', + success: true, + result: execution.result, + performance: execution.performance, + }); } else { - emitEvent(payload, { - status: 'error', - phase: payload.url ? 'fetching' : 'reading-file', - error: - error instanceof Error ? error.message : String(error), + const error = execution.error; + if (error instanceof Error && error.name === 'AbortError') { + emitEvent(payload, { + status: 'cancelled', + phase: 'parsing', + }); + } else { + emitEvent(payload, { + status: 'error', + phase: payload.url ? 'fetching' : 'reading-file', + error: + error instanceof Error + ? error.message + : String(error), + }); + } + + postMessage({ + type: 'response', + success: false, + error: serializeError(error), + performance: execution.performance, }); } - - const performance = - await finishWorkerPerformanceCapture(performanceCapture); - postMessage({ - type: 'response', - success: false, - error: serializeError(error), - performance, - }); + } finally { + releaseActiveRefresh(payload.operationId, activeRefresh); } } ); diff --git a/apps/electron-backend/src/app/workers/worker-performance-cancellation.spec.ts b/apps/electron-backend/src/app/workers/worker-performance-cancellation.spec.ts new file mode 100644 index 000000000..eef0171f3 --- /dev/null +++ b/apps/electron-backend/src/app/workers/worker-performance-cancellation.spec.ts @@ -0,0 +1,284 @@ +import type { + DbWorkerIncomingMessage, + DbWorkerMessage, +} from './database-worker.types'; +import type { + PlaylistRefreshWorkerIncomingMessage, + PlaylistRefreshWorkerMessage, +} from './playlist-refresh.worker.types'; + +type WorkerMessageHandler = ( + message: TMessage +) => Promise | void; + +interface ArmGate { + readonly promise: Promise; + readonly release: () => void; +} + +function createArmGate(): ArmGate { + let release = (): void => undefined; + const promise = new Promise((resolvePromise) => { + release = resolvePromise; + }); + return { promise, release }; +} + +function createParentPortHarness() { + let messageHandler: WorkerMessageHandler | null = null; + const postMessage = jest.fn(); + const parentPort = { + on: jest.fn( + (event: string, handler: WorkerMessageHandler): void => { + if (event === 'message') { + messageHandler = handler; + } + } + ), + postMessage, + }; + + return { + getMessageHandler: (): WorkerMessageHandler => { + if (!messageHandler) { + throw new Error('Worker message handler was not registered'); + } + return messageHandler; + }, + parentPort, + postMessage, + }; +} + +function mockPerformanceCapture(armGate: ArmGate): void { + jest.doMock('./worker-performance-capture', () => ({ + armWorkerPerformanceCapture: jest.fn(() => armGate.promise), + executeWithWorkerPerformanceCapture: jest.fn( + async (_capture: unknown, execute: () => Promise) => { + try { + return { + error: null, + performance: undefined, + result: await execute(), + success: true, + }; + } catch (error) { + return { + error, + performance: undefined, + result: undefined, + success: false, + }; + } + } + ), + registerDatabaseWorkerPerformanceCapture: jest.fn(), + releaseDatabaseWorkerPerformanceCapture: jest.fn(), + startWorkerPerformanceCapture: jest.fn(() => ({})), + })); +} + +describe('worker cancellation while performance capture arms', () => { + afterEach(() => { + jest.restoreAllMocks(); + jest.resetModules(); + }); + + it('cancels playlist work before file access when cancel arrives during arming', async () => { + const armGate = createArmGate(); + const port = createParentPortHarness< + PlaylistRefreshWorkerIncomingMessage, + PlaylistRefreshWorkerMessage + >(); + const readFile = jest.fn().mockResolvedValue('#EXTM3U'); + mockPerformanceCapture(armGate); + jest.doMock('worker_threads', () => ({ + parentPort: port.parentPort, + })); + jest.doMock('node:fs/promises', () => ({ readFile })); + jest.doMock('iptv-playlist-parser', () => ({ + parse: jest.fn(() => ({ items: [] })), + })); + jest.doMock('@iptvnator/shared/m3u-utils', () => ({ + createPlaylistObject: jest.fn(() => ({ id: 'playlist-1' })), + getFilenameFromUrl: jest.fn(() => 'playlist.m3u'), + })); + + await import('./playlist-refresh.worker'); + port.postMessage.mockClear(); + const handleMessage = port.getMessageHandler(); + const requestPromise = handleMessage({ + type: 'request', + payload: { + filePath: '/fixtures/playlist.m3u', + operationId: 'refresh-1', + playlistId: 'playlist-1', + title: 'Fixture', + }, + }); + + await handleMessage({ + type: 'cancel', + operationId: 'refresh-1', + }); + armGate.release(); + await requestPromise; + + expect(readFile).not.toHaveBeenCalled(); + expect(port.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + operationId: 'refresh-1', + status: 'cancelled', + }), + type: 'event', + }) + ); + expect(port.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.objectContaining({ name: 'AbortError' }), + success: false, + type: 'response', + }) + ); + }); + + it('cancels cancellable database work when cancel arrives during arming', async () => { + const armGate = createArmGate(); + const port = createParentPortHarness< + DbWorkerIncomingMessage, + DbWorkerMessage + >(); + const saveContent = jest.fn( + async ( + _db: unknown, + _playlistId: string, + _streams: unknown[], + _type: string, + control: { checkpoint: () => Promise } + ) => { + await control.checkpoint(); + return { count: 0 }; + } + ); + mockPerformanceCapture(armGate); + jest.doMock('worker_threads', () => ({ + parentPort: port.parentPort, + })); + jest.doMock('./database.worker-connection', () => ({ + closeWorkerDatabase: jest.fn(), + getWorkerDatabase: jest.fn().mockResolvedValue({}), + })); + jest.doMock('../database/operations/content.operations', () => ({ + saveContent, + })); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + + await import('./database.worker'); + port.postMessage.mockClear(); + const handleMessage = port.getMessageHandler(); + const requestPromise = handleMessage({ + type: 'request', + operation: 'DB_SAVE_CONTENT', + payload: { + operationId: 'database-operation-1', + playlistId: 'playlist-1', + streams: [], + type: 'movie', + }, + requestId: 'request-1', + }); + + await handleMessage({ + type: 'cancel', + operationId: 'database-operation-1', + }); + armGate.release(); + await requestPromise; + + expect(saveContent).toHaveBeenCalledTimes(1); + expect(port.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + operationId: 'database-operation-1', + status: 'cancelled', + }), + requestId: 'request-1', + type: 'event', + }) + ); + expect(port.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.objectContaining({ name: 'AbortError' }), + requestId: 'request-1', + success: false, + type: 'response', + }) + ); + }); + + it('does not make an explicitly non-cancellable database operation cancellable during arming', async () => { + const armGate = createArmGate(); + const port = createParentPortHarness< + DbWorkerIncomingMessage, + DbWorkerMessage + >(); + const deleteAllPlaylists = jest.fn( + async ( + _db: unknown, + control: { checkpoint: () => Promise } + ) => { + await control.checkpoint(); + return { success: true }; + } + ); + mockPerformanceCapture(armGate); + jest.doMock('worker_threads', () => ({ + parentPort: port.parentPort, + })); + jest.doMock('./database.worker-connection', () => ({ + closeWorkerDatabase: jest.fn(), + getWorkerDatabase: jest.fn().mockResolvedValue({}), + })); + jest.doMock('../database/operations/playlist.operations', () => ({ + deleteAllPlaylists, + })); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + + await import('./database.worker'); + port.postMessage.mockClear(); + const handleMessage = port.getMessageHandler(); + const requestPromise = handleMessage({ + type: 'request', + operation: 'DB_DELETE_ALL_PLAYLISTS', + payload: { + operationId: 'database-operation-2', + }, + requestId: 'request-2', + }); + + await handleMessage({ + type: 'cancel', + operationId: 'database-operation-2', + }); + armGate.release(); + await requestPromise; + + expect(deleteAllPlaylists).toHaveBeenCalledTimes(1); + expect(port.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + requestId: 'request-2', + result: { success: true }, + success: true, + type: 'response', + }) + ); + expect(port.postMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ status: 'cancelled' }), + requestId: 'request-2', + type: 'event', + }) + ); + }); +}); diff --git a/apps/electron-backend/src/app/workers/worker-performance-capture.concurrency.spec.ts b/apps/electron-backend/src/app/workers/worker-performance-capture.concurrency.spec.ts new file mode 100644 index 000000000..900f4ccde --- /dev/null +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.concurrency.spec.ts @@ -0,0 +1,95 @@ +import { + WORKER_PERFORMANCE_INVALID_REASON, + armWorkerPerformanceCapture, + executeWithWorkerPerformanceCapture, + registerDatabaseWorkerPerformanceCapture, + releaseDatabaseWorkerPerformanceCapture, + startWorkerPerformanceCapture, + type WorkerPerformanceCapture, +} from './worker-performance-capture'; +import { createFakeRuntime } from './worker-performance-capture.test-harness'; + +const PROFILING_ENV = 'IPTVNATOR_PERF_WORKER_PROFILING'; + +describe('worker performance capture concurrency and real timers', () => { + const originalProfilingValue = process.env[PROFILING_ENV]; + + afterEach(() => { + if (originalProfilingValue === undefined) { + delete process.env[PROFILING_ENV]; + } else { + process.env[PROFILING_ENV] = originalProfilingValue; + } + }); + + it('fails every overlapping database request capture closed without serializing execution', async () => { + const activeCaptures = new Set(); + const firstHarness = createFakeRuntime(); + const secondHarness = createFakeRuntime(); + const first = startWorkerPerformanceCapture({ + enabled: true, + runtime: firstHarness.runtime, + }); + const second = startWorkerPerformanceCapture({ + enabled: true, + runtime: secondHarness.runtime, + }); + registerDatabaseWorkerPerformanceCapture(activeCaptures, first); + registerDatabaseWorkerPerformanceCapture(activeCaptures, second); + await Promise.all([ + armWorkerPerformanceCapture(first), + armWorkerPerformanceCapture(second), + ]); + + const [firstExecution, secondExecution] = await Promise.all([ + executeWithWorkerPerformanceCapture(first, async () => 'first'), + executeWithWorkerPerformanceCapture(second, async () => 'second'), + ]); + releaseDatabaseWorkerPerformanceCapture(activeCaptures, first); + releaseDatabaseWorkerPerformanceCapture(activeCaptures, second); + + for (const execution of [firstExecution, secondExecution]) { + expect(execution.performance).toMatchObject({ + eventLoopDelay: null, + eventLoopDelayUnavailableReason: + WORKER_PERFORMANCE_INVALID_REASON.OVERLAPPING_DATABASE_WORKER_REQUESTS, + eventLoopUtilization: null, + eventLoopUtilizationUnavailableReason: + WORKER_PERFORMANCE_INVALID_REASON.OVERLAPPING_DATABASE_WORKER_REQUESTS, + histogramFlushedEpochMs: null, + invalidReason: + WORKER_PERFORMANCE_INVALID_REASON.OVERLAPPING_DATABASE_WORKER_REQUESTS, + threadCpuSystemMicros: null, + threadCpuUnavailableReason: + WORKER_PERFORMANCE_INVALID_REASON.OVERLAPPING_DATABASE_WORKER_REQUESTS, + threadCpuUserMicros: null, + }); + } + expect(firstExecution.result).toBe('first'); + expect(secondExecution.result).toBe('second'); + expect(activeCaptures.size).toBe(0); + }); + + it('reliably observes a real 20ms event-loop block after condition-based arming', async () => { + process.env[PROFILING_ENV] = '1'; + + const capture = startWorkerPerformanceCapture(); + await armWorkerPerformanceCapture(capture); + const execution = await executeWithWorkerPerformanceCapture( + capture, + async () => { + const blockStartedAt = performance.now(); + while (performance.now() - blockStartedAt < 20) { + // This deliberate block is the behavior under measurement. + } + } + ); + + expect(execution.success).toBe(true); + expect(execution.performance?.eventLoopDelay).not.toBeNull(); + expect(execution.performance?.eventLoopDelay?.maxMs).toBeGreaterThan( + 10 + ); + expect(execution.performance?.histogramFlushedEpochMs).not.toBeNull(); + }); +}); diff --git a/apps/electron-backend/src/app/workers/worker-performance-capture.histogram.spec.ts b/apps/electron-backend/src/app/workers/worker-performance-capture.histogram.spec.ts new file mode 100644 index 000000000..19462fe5a --- /dev/null +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.histogram.spec.ts @@ -0,0 +1,39 @@ +import { + armWorkerPerformanceCapture, + executeWithWorkerPerformanceCapture, + startWorkerPerformanceCapture, +} from './worker-performance-capture'; +import { createFakeRuntime } from './worker-performance-capture.test-harness'; + +describe('worker performance histogram lifecycle', () => { + it('accepts a nonthrowing false disable result because the histogram is already disabled', async () => { + const harness = createFakeRuntime({ + histogramDisableResult: false, + }); + const capture = startWorkerPerformanceCapture({ + enabled: true, + runtime: harness.runtime, + }); + await armWorkerPerformanceCapture(capture); + + const execution = await executeWithWorkerPerformanceCapture( + capture, + async () => 'business-result' + ); + + expect(execution).toMatchObject({ + error: null, + result: 'business-result', + success: true, + performance: { + eventLoopDelay: { + maxMs: 24, + p95Ms: 18, + p99Ms: 22, + }, + eventLoopDelayUnavailableReason: null, + histogramFlushedEpochMs: 145, + }, + }); + }); +}); diff --git a/apps/electron-backend/src/app/workers/worker-performance-capture.histogram.ts b/apps/electron-backend/src/app/workers/worker-performance-capture.histogram.ts new file mode 100644 index 000000000..a4951d6cc --- /dev/null +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.histogram.ts @@ -0,0 +1,136 @@ +import { + WORKER_PERFORMANCE_UNAVAILABLE_REASON, + type WorkerEventLoopDelayHistogram, + type WorkerEventLoopDelayMetrics, + type WorkerPerformanceCapture, + type WorkerPerformanceMetricUnavailableReason, +} from './worker-performance-capture.model'; +import { + HISTOGRAM_POLL_INTERVAL_MS, + HISTOGRAM_WAIT_CAP_MS, +} from './worker-performance-capture.runtime'; + +export function disableWorkerPerformanceHistogram( + capture: WorkerPerformanceCapture +): boolean { + if (!capture.eventLoopDelay || capture.histogramDisabled) { + return true; + } + + try { + capture.eventLoopDelay.disable(); + capture.histogramDisabled = true; + return true; + } catch { + // Performance instrumentation must never affect worker execution. + return false; + } +} + +export function markEventLoopDelayUnavailable( + capture: WorkerPerformanceCapture, + reason: WorkerPerformanceMetricUnavailableReason +): void { + capture.eventLoopDelayUnavailableReason ??= reason; + disableWorkerPerformanceHistogram(capture); +} + +export async function waitForHistogramCondition( + capture: WorkerPerformanceCapture, + condition: (histogram: WorkerEventLoopDelayHistogram) => boolean +): Promise { + const histogram = capture.eventLoopDelay; + if ( + !histogram || + capture.histogramDisabled || + capture.invalidReason !== null + ) { + return false; + } + + let startedAt: number; + try { + startedAt = capture.runtime.readMonotonicMs(); + } catch { + return false; + } + + const maximumPolls = Math.ceil( + HISTOGRAM_WAIT_CAP_MS / HISTOGRAM_POLL_INTERVAL_MS + ); + let pollCount = 0; + + while (true) { + if (capture.invalidReason !== null || capture.histogramDisabled) { + return false; + } + try { + if (condition(histogram)) { + return true; + } + } catch { + return false; + } + + let elapsedMs: number; + try { + elapsedMs = capture.runtime.readMonotonicMs() - startedAt; + } catch { + return false; + } + if ( + !Number.isFinite(elapsedMs) || + elapsedMs >= HISTOGRAM_WAIT_CAP_MS || + pollCount >= maximumPolls + ) { + return false; + } + + const delayMs = Math.min( + HISTOGRAM_POLL_INTERVAL_MS, + HISTOGRAM_WAIT_CAP_MS - Math.max(0, elapsedMs) + ); + try { + pollCount += 1; + await new Promise((resolvePromise) => { + capture.runtime.scheduleTimeout(resolvePromise, delayMs); + }); + } catch { + return false; + } + } +} + +export function readWorkerEventLoopDelay( + capture: WorkerPerformanceCapture +): WorkerEventLoopDelayMetrics | null { + if ( + capture.invalidReason !== null || + capture.eventLoopDelayUnavailableReason !== null || + capture.histogramFlushedEpochMs === null || + !capture.eventLoopDelay + ) { + return null; + } + + try { + const metrics: WorkerEventLoopDelayMetrics = { + maxMs: capture.eventLoopDelay.max / 1e6, + p95Ms: capture.eventLoopDelay.percentile(95) / 1e6, + p99Ms: capture.eventLoopDelay.percentile(99) / 1e6, + }; + if ( + Object.values(metrics).every( + (value) => Number.isFinite(value) && value >= 0 + ) + ) { + return metrics; + } + } catch { + // The fixed reason below is intentionally payload-free. + } + + capture.eventLoopDelayUnavailableReason = + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_INVALID; + return null; +} diff --git a/apps/electron-backend/src/app/workers/worker-performance-capture.metrics.ts b/apps/electron-backend/src/app/workers/worker-performance-capture.metrics.ts new file mode 100644 index 000000000..4d1fb3a1f --- /dev/null +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.metrics.ts @@ -0,0 +1,235 @@ +import type { EventLoopUtilization } from 'node:perf_hooks'; +import { + WORKER_PERFORMANCE_UNAVAILABLE_REASON, + type WorkerPerformanceCapture, + type WorkerPerformanceCaptureResult, +} from './worker-performance-capture.model'; +import { readWorkerEventLoopDelay } from './worker-performance-capture.histogram'; +import { safeReadEpochMs } from './worker-performance-capture.runtime'; + +function readThreadCpuBoundary( + capture: WorkerPerformanceCapture +): NodeJS.CpuUsage | null { + if (!capture.runtime.readThreadCpuUsage) { + capture.threadCpuUnavailableReason ??= + WORKER_PERFORMANCE_UNAVAILABLE_REASON.THREAD_CPU_USAGE_UNAVAILABLE; + return null; + } + + try { + const usage = capture.runtime.readThreadCpuUsage(); + if ( + !Number.isFinite(usage.system) || + !Number.isFinite(usage.user) || + usage.system < 0 || + usage.user < 0 + ) { + capture.threadCpuUnavailableReason = + WORKER_PERFORMANCE_UNAVAILABLE_REASON.THREAD_CPU_USAGE_INVALID; + return null; + } + return usage; + } catch { + capture.threadCpuUnavailableReason = + WORKER_PERFORMANCE_UNAVAILABLE_REASON.THREAD_CPU_USAGE_UNAVAILABLE; + return null; + } +} + +function readEventLoopUtilizationBoundary( + capture: WorkerPerformanceCapture +): EventLoopUtilization | null { + try { + const utilization = capture.runtime.readEventLoopUtilization(); + if ( + !Number.isFinite(utilization.active) || + !Number.isFinite(utilization.idle) + ) { + capture.eventLoopUtilizationUnavailableReason = + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_UTILIZATION_UNAVAILABLE; + return null; + } + return utilization; + } catch { + capture.eventLoopUtilizationUnavailableReason = + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_UTILIZATION_UNAVAILABLE; + return null; + } +} + +export function beginWorkerPerformanceCapture( + capture: WorkerPerformanceCapture | null +): void { + if (!capture) { + return; + } + + capture.workStartedEpochMs = safeReadEpochMs(capture.runtime); + capture.threadCpuStart = readThreadCpuBoundary(capture); + capture.eventLoopUtilizationStart = + readEventLoopUtilizationBoundary(capture); +} + +function calculateThreadCpu( + capture: WorkerPerformanceCapture +): Pick< + WorkerPerformanceCaptureResult, + 'threadCpuSystemMicros' | 'threadCpuUserMicros' +> { + if ( + capture.invalidReason !== null || + capture.threadCpuUnavailableReason !== null || + !capture.threadCpuStart || + !capture.threadCpuEnd + ) { + return { + threadCpuSystemMicros: null, + threadCpuUserMicros: null, + }; + } + + try { + const system = + capture.threadCpuEnd.system - capture.threadCpuStart.system; + const user = capture.threadCpuEnd.user - capture.threadCpuStart.user; + if ( + !Number.isFinite(system) || + !Number.isFinite(user) || + system < 0 || + user < 0 + ) { + capture.threadCpuUnavailableReason = + WORKER_PERFORMANCE_UNAVAILABLE_REASON.THREAD_CPU_USAGE_INVALID; + return { + threadCpuSystemMicros: null, + threadCpuUserMicros: null, + }; + } + + return { + threadCpuSystemMicros: system, + threadCpuUserMicros: user, + }; + } catch { + capture.threadCpuUnavailableReason = + WORKER_PERFORMANCE_UNAVAILABLE_REASON.THREAD_CPU_USAGE_INVALID; + return { + threadCpuSystemMicros: null, + threadCpuUserMicros: null, + }; + } +} + +function calculateEventLoopUtilization( + capture: WorkerPerformanceCapture +): number | null { + if ( + capture.invalidReason !== null || + capture.eventLoopUtilizationUnavailableReason !== null || + !capture.eventLoopUtilizationStart || + !capture.eventLoopUtilizationEnd + ) { + return null; + } + + try { + const active = + capture.eventLoopUtilizationEnd.active - + capture.eventLoopUtilizationStart.active; + const idle = + capture.eventLoopUtilizationEnd.idle - + capture.eventLoopUtilizationStart.idle; + const total = active + idle; + if ( + !Number.isFinite(active) || + !Number.isFinite(idle) || + active < 0 || + idle < 0 || + total < 0 + ) { + capture.eventLoopUtilizationUnavailableReason = + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_UTILIZATION_UNAVAILABLE; + return null; + } + + return total === 0 ? 0 : active / total; + } catch { + capture.eventLoopUtilizationUnavailableReason = + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_UTILIZATION_UNAVAILABLE; + return null; + } +} + +function createFallbackResult( + capture: WorkerPerformanceCapture +): WorkerPerformanceCaptureResult { + const invalidReason = capture.invalidReason; + return { + eventLoopDelay: null, + eventLoopDelayUnavailableReason: + invalidReason ?? + capture.eventLoopDelayUnavailableReason ?? + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_CAPTURE_UNAVAILABLE, + eventLoopUtilization: null, + eventLoopUtilizationUnavailableReason: + invalidReason ?? + capture.eventLoopUtilizationUnavailableReason ?? + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_UTILIZATION_UNAVAILABLE, + histogramFlushedEpochMs: null, + invalidReason, + requestReceivedEpochMs: capture.requestReceivedEpochMs, + threadCpuSystemMicros: null, + threadCpuUnavailableReason: + invalidReason ?? + capture.threadCpuUnavailableReason ?? + WORKER_PERFORMANCE_UNAVAILABLE_REASON.THREAD_CPU_USAGE_UNAVAILABLE, + threadCpuUserMicros: null, + workEndedEpochMs: + capture.workEndedEpochMs ?? capture.requestReceivedEpochMs, + workStartedEpochMs: + capture.workStartedEpochMs ?? capture.requestReceivedEpochMs, + }; +} + +export function createWorkerPerformanceCaptureResult( + capture: WorkerPerformanceCapture +): WorkerPerformanceCaptureResult { + try { + const overlapReason = capture.invalidReason; + if (overlapReason) { + capture.eventLoopDelayUnavailableReason = overlapReason; + capture.eventLoopUtilizationUnavailableReason = overlapReason; + capture.threadCpuUnavailableReason = overlapReason; + capture.histogramFlushedEpochMs = null; + } + const threadCpu = calculateThreadCpu(capture); + + return { + eventLoopDelay: readWorkerEventLoopDelay(capture), + eventLoopDelayUnavailableReason: + capture.eventLoopDelayUnavailableReason, + eventLoopUtilization: calculateEventLoopUtilization(capture), + eventLoopUtilizationUnavailableReason: + capture.eventLoopUtilizationUnavailableReason, + histogramFlushedEpochMs: capture.histogramFlushedEpochMs, + invalidReason: capture.invalidReason, + requestReceivedEpochMs: capture.requestReceivedEpochMs, + ...threadCpu, + threadCpuUnavailableReason: capture.threadCpuUnavailableReason, + workEndedEpochMs: + capture.workEndedEpochMs ?? capture.requestReceivedEpochMs, + workStartedEpochMs: + capture.workStartedEpochMs ?? capture.requestReceivedEpochMs, + }; + } catch { + return createFallbackResult(capture); + } +} + +export function finishWorkerPerformanceMetricBoundaries( + capture: WorkerPerformanceCapture +): void { + capture.workEndedEpochMs = safeReadEpochMs(capture.runtime); + capture.threadCpuEnd = readThreadCpuBoundary(capture); + capture.eventLoopUtilizationEnd = readEventLoopUtilizationBoundary(capture); +} diff --git a/apps/electron-backend/src/app/workers/worker-performance-capture.model.ts b/apps/electron-backend/src/app/workers/worker-performance-capture.model.ts new file mode 100644 index 000000000..f86576eda --- /dev/null +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.model.ts @@ -0,0 +1,94 @@ +import type { EventLoopUtilization } from 'node:perf_hooks'; + +export const WORKER_PERFORMANCE_INVALID_REASON = { + OVERLAPPING_DATABASE_WORKER_REQUESTS: + 'overlapping-database-worker-requests', +} as const; + +export type WorkerPerformanceInvalidReason = + (typeof WORKER_PERFORMANCE_INVALID_REASON)[keyof typeof WORKER_PERFORMANCE_INVALID_REASON]; + +export const WORKER_PERFORMANCE_UNAVAILABLE_REASON = { + EVENT_LOOP_DELAY_ARM_TIMEOUT: 'event-loop-delay-arm-timeout', + EVENT_LOOP_DELAY_CAPTURE_UNAVAILABLE: + 'event-loop-delay-capture-unavailable', + EVENT_LOOP_DELAY_FLUSH_TIMEOUT: 'event-loop-delay-flush-timeout', + EVENT_LOOP_DELAY_INVALID: 'event-loop-delay-invalid', + EVENT_LOOP_UTILIZATION_UNAVAILABLE: 'event-loop-utilization-unavailable', + THREAD_CPU_USAGE_INVALID: 'thread-cpu-usage-invalid', + THREAD_CPU_USAGE_UNAVAILABLE: 'thread-cpu-usage-unavailable', +} as const; + +export type WorkerPerformanceUnavailableReason = + (typeof WORKER_PERFORMANCE_UNAVAILABLE_REASON)[keyof typeof WORKER_PERFORMANCE_UNAVAILABLE_REASON]; + +export type WorkerPerformanceMetricUnavailableReason = + WorkerPerformanceInvalidReason | WorkerPerformanceUnavailableReason; + +export interface WorkerEventLoopDelayMetrics { + maxMs: number; + p95Ms: number; + p99Ms: number; +} + +export interface WorkerEventLoopDelayHistogram { + readonly count: number; + readonly max: number; + disable: () => boolean; + enable: () => boolean; + percentile: (percentile: number) => number; +} + +export interface WorkerPerformanceCaptureResult { + eventLoopDelay: WorkerEventLoopDelayMetrics | null; + eventLoopDelayUnavailableReason: WorkerPerformanceMetricUnavailableReason | null; + eventLoopUtilization: number | null; + eventLoopUtilizationUnavailableReason: WorkerPerformanceMetricUnavailableReason | null; + histogramFlushedEpochMs: number | null; + invalidReason: WorkerPerformanceInvalidReason | null; + requestReceivedEpochMs: number; + threadCpuSystemMicros: number | null; + threadCpuUnavailableReason: WorkerPerformanceMetricUnavailableReason | null; + threadCpuUserMicros: number | null; + workEndedEpochMs: number; + workStartedEpochMs: number; +} + +export interface WorkerPerformanceCaptureRuntime { + createEventLoopDelayHistogram: () => WorkerEventLoopDelayHistogram; + readEpochMs: () => number; + readEventLoopUtilization: () => EventLoopUtilization; + readMonotonicMs: () => number; + readThreadCpuUsage: (() => NodeJS.CpuUsage) | null; + scheduleTimeout: (callback: () => void, delayMs: number) => void; +} + +export interface WorkerPerformanceCaptureOptions { + enabled?: boolean; + runtime?: WorkerPerformanceCaptureRuntime; +} + +export interface WorkerPerformanceExecutionResult { + error: unknown | null; + performance: WorkerPerformanceCaptureResult | undefined; + result: TResult | undefined; + success: boolean; +} + +export interface WorkerPerformanceCapture { + eventLoopDelay: WorkerEventLoopDelayHistogram | null; + eventLoopDelayUnavailableReason: WorkerPerformanceMetricUnavailableReason | null; + eventLoopUtilizationEnd: EventLoopUtilization | null; + eventLoopUtilizationStart: EventLoopUtilization | null; + eventLoopUtilizationUnavailableReason: WorkerPerformanceMetricUnavailableReason | null; + histogramDisabled: boolean; + histogramFlushedEpochMs: number | null; + invalidReason: WorkerPerformanceInvalidReason | null; + requestReceivedEpochMs: number; + runtime: WorkerPerformanceCaptureRuntime; + threadCpuEnd: NodeJS.CpuUsage | null; + threadCpuStart: NodeJS.CpuUsage | null; + threadCpuUnavailableReason: WorkerPerformanceMetricUnavailableReason | null; + workEndedEpochMs: number | null; + workStartedEpochMs: number | null; +} diff --git a/apps/electron-backend/src/app/workers/worker-performance-capture.runtime.ts b/apps/electron-backend/src/app/workers/worker-performance-capture.runtime.ts new file mode 100644 index 000000000..15c0a4308 --- /dev/null +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.runtime.ts @@ -0,0 +1,45 @@ +import { monitorEventLoopDelay, performance } from 'node:perf_hooks'; +import type { + WorkerPerformanceCaptureRuntime, + WorkerEventLoopDelayHistogram, +} from './worker-performance-capture.model'; + +export const WORKER_PROFILING_ENV = 'IPTVNATOR_PERF_WORKER_PROFILING'; +export const HISTOGRAM_POLL_INTERVAL_MS = 1; +export const HISTOGRAM_WAIT_CAP_MS = 50; + +function readRuntimeThreadCpuUsage(): (() => NodeJS.CpuUsage) | null { + const candidate: unknown = Reflect.get(process, 'threadCpuUsage'); + if (typeof candidate !== 'function') { + return null; + } + + return () => + (candidate as (this: NodeJS.Process) => NodeJS.CpuUsage).call(process); +} + +export const DEFAULT_WORKER_PERFORMANCE_RUNTIME: WorkerPerformanceCaptureRuntime = + { + createEventLoopDelayHistogram: (): WorkerEventLoopDelayHistogram => + monitorEventLoopDelay({ + resolution: HISTOGRAM_POLL_INTERVAL_MS, + }), + readEpochMs: () => performance.timeOrigin + performance.now(), + readEventLoopUtilization: () => performance.eventLoopUtilization(), + readMonotonicMs: () => performance.now(), + readThreadCpuUsage: readRuntimeThreadCpuUsage(), + scheduleTimeout: (callback, delayMs) => { + setTimeout(callback, delayMs); + }, + }; + +export function safeReadEpochMs( + runtime: WorkerPerformanceCaptureRuntime +): number { + try { + const epochMs = runtime.readEpochMs(); + return Number.isFinite(epochMs) ? epochMs : Date.now(); + } catch { + return Date.now(); + } +} diff --git a/apps/electron-backend/src/app/workers/worker-performance-capture.spec.ts b/apps/electron-backend/src/app/workers/worker-performance-capture.spec.ts index 2801935c3..7bebd4fe9 100644 --- a/apps/electron-backend/src/app/workers/worker-performance-capture.spec.ts +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.spec.ts @@ -1,8 +1,10 @@ import { + WORKER_PERFORMANCE_UNAVAILABLE_REASON, armWorkerPerformanceCapture, - finishWorkerPerformanceCapture, + executeWithWorkerPerformanceCapture, startWorkerPerformanceCapture, } from './worker-performance-capture'; +import { createFakeRuntime } from './worker-performance-capture.test-harness'; const PROFILING_ENV = 'IPTVNATOR_PERF_WORKER_PROFILING'; @@ -23,26 +25,355 @@ describe('worker performance capture', () => { expect(startWorkerPerformanceCapture()).toBeNull(); }); - it('reports finite worker event-loop metrics when opted in', async () => { - process.env[PROFILING_ENV] = '1'; - - const capture = startWorkerPerformanceCapture(); - await armWorkerPerformanceCapture(capture); - const blockStartedAt = performance.now(); - while (performance.now() - blockStartedAt < 20) { - // Deliberately block the loop so the histogram contract is tested. - } - const result = await finishWorkerPerformanceCapture(capture); - - expect(result).toEqual({ - eventLoopDelay: { - maxMs: expect.any(Number), - p95Ms: expect.any(Number), - p99Ms: expect.any(Number), - }, - eventLoopUtilization: expect.any(Number), + it('captures exact work-boundary CPU, ELU, timestamps, and a flushed fresh histogram', async () => { + const harness = createFakeRuntime(); + const capture = startWorkerPerformanceCapture({ + enabled: true, + runtime: harness.runtime, + }); + await armWorkerPerformanceCapture(capture); + + const execution = await executeWithWorkerPerformanceCapture( + capture, + async () => { + harness.lifecycle.push('execute'); + await Promise.resolve(); + harness.lifecycle.push('execute:settled'); + return 'result'; + } + ); + + expect(execution).toEqual({ + error: null, + performance: { + eventLoopDelay: { + maxMs: 24, + p95Ms: 18, + p99Ms: 22, + }, + eventLoopDelayUnavailableReason: null, + eventLoopUtilization: 0.75, + eventLoopUtilizationUnavailableReason: null, + histogramFlushedEpochMs: 145, + invalidReason: null, + requestReceivedEpochMs: 100, + threadCpuSystemMicros: 30, + threadCpuUnavailableReason: null, + threadCpuUserMicros: 80, + workEndedEpochMs: 140, + workStartedEpochMs: 110, + }, + result: 'result', + success: true, + }); + expect(harness.lifecycle).toEqual([ + 'epoch:100', + 'histogram:create', + 'timeout:1', + 'epoch:110', + 'cpu:1', + 'elu:1', + 'execute', + 'execute:settled', + 'epoch:140', + 'cpu:2', + 'elu:2', + 'timeout:1', + 'epoch:145', + ]); + expect(harness.histogramLifecycle.enableCalls).toBe(1); + expect(harness.histogramLifecycle.disableCalls).toBe(1); + expect('reset' in harness.histogram).toBe(false); + }); + + it('finishes profiling before returning a rejected execution to worker side effects', async () => { + const harness = createFakeRuntime(); + const capture = startWorkerPerformanceCapture({ + enabled: true, + runtime: harness.runtime, + }); + const failure = new Error('worker failed'); + await armWorkerPerformanceCapture(capture); + + const execution = await executeWithWorkerPerformanceCapture( + capture, + async () => { + harness.lifecycle.push('execute'); + await Promise.resolve(); + harness.lifecycle.push('execute:rejected'); + throw failure; + } + ); + harness.lifecycle.push('worker:emit-error'); + + expect(execution).toMatchObject({ + error: failure, + success: false, + }); + expect(harness.lifecycle.indexOf('epoch:140')).toBe( + harness.lifecycle.indexOf('execute:rejected') + 1 + ); + expect(harness.lifecycle.indexOf('worker:emit-error')).toBeGreaterThan( + harness.lifecycle.indexOf('epoch:145') + ); + }); + + it('times out arming after a monotonic 50ms without blocking work metrics', async () => { + const harness = createFakeRuntime({ + armHistogram: false, + }); + const capture = startWorkerPerformanceCapture({ + enabled: true, + runtime: harness.runtime, + }); + + await armWorkerPerformanceCapture(capture); + const execution = await executeWithWorkerPerformanceCapture( + capture, + async () => 'result' + ); + + expect( + harness.scheduledTimeouts.reduce( + (total, timeout) => total + timeout, + 0 + ) + ).toBe(50); + expect(execution.performance).toMatchObject({ + eventLoopDelay: null, + eventLoopDelayUnavailableReason: + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_ARM_TIMEOUT, + eventLoopUtilization: 0.75, + histogramFlushedEpochMs: null, + threadCpuSystemMicros: 30, + threadCpuUserMicros: 80, + }); + }); + + it('cannot poll forever when the monotonic runtime clock stalls', async () => { + const harness = createFakeRuntime({ + armHistogram: false, + stallMonotonicClock: true, + }); + const capture = startWorkerPerformanceCapture({ + enabled: true, + runtime: harness.runtime, + }); + + await armWorkerPerformanceCapture(capture); + const execution = await executeWithWorkerPerformanceCapture( + capture, + async () => 'result' + ); + + expect(harness.scheduledTimeouts).toHaveLength(50); + expect(execution).toMatchObject({ + result: 'result', + success: true, + performance: { + eventLoopDelay: null, + eventLoopDelayUnavailableReason: + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_ARM_TIMEOUT, + }, + }); + }); + + it('times out flushing after a monotonic 50ms while preserving work CPU and ELU', async () => { + const harness = createFakeRuntime({ + flushHistogram: false, + }); + const capture = startWorkerPerformanceCapture({ + enabled: true, + runtime: harness.runtime, + }); + await armWorkerPerformanceCapture(capture); + + const execution = await executeWithWorkerPerformanceCapture( + capture, + async () => 'result' + ); + + expect( + harness.scheduledTimeouts + .slice(1) + .reduce((total, timeout) => total + timeout, 0) + ).toBe(50); + expect(execution.performance).toMatchObject({ + eventLoopDelay: null, + eventLoopDelayUnavailableReason: + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_FLUSH_TIMEOUT, + eventLoopUtilization: 0.75, + histogramFlushedEpochMs: null, + threadCpuSystemMicros: 30, + threadCpuUserMicros: 80, + }); + }); + + it('reports unavailable thread CPU without falling back to process CPU usage', async () => { + const harness = createFakeRuntime({ + threadCpuAvailable: false, + }); + const capture = startWorkerPerformanceCapture({ + enabled: true, + runtime: harness.runtime, + }); + await armWorkerPerformanceCapture(capture); + + const execution = await executeWithWorkerPerformanceCapture( + capture, + async () => 'result' + ); + + expect(execution.performance).toMatchObject({ + eventLoopUtilization: 0.75, + threadCpuSystemMicros: null, + threadCpuUnavailableReason: + WORKER_PERFORMANCE_UNAVAILABLE_REASON.THREAD_CPU_USAGE_UNAVAILABLE, + threadCpuUserMicros: null, + }); + expect(harness.lifecycle).not.toContain('cpu:1'); + expect(harness.lifecycle).not.toContain('cpu:2'); + }); + + it('preserves the business result when CPU and ELU runtime callbacks throw', async () => { + const harness = createFakeRuntime({ + throwBoundaryCallbacks: true, + }); + const capture = startWorkerPerformanceCapture({ + enabled: true, + runtime: harness.runtime, + }); + await armWorkerPerformanceCapture(capture); + + const execution = await executeWithWorkerPerformanceCapture( + capture, + async () => 'business-result' + ); + + expect(execution).toMatchObject({ + error: null, + result: 'business-result', + success: true, + performance: { + eventLoopDelay: { + maxMs: 24, + p95Ms: 18, + p99Ms: 22, + }, + eventLoopUtilization: null, + eventLoopUtilizationUnavailableReason: + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_UTILIZATION_UNAVAILABLE, + threadCpuSystemMicros: null, + threadCpuUnavailableReason: + WORKER_PERFORMANCE_UNAVAILABLE_REASON.THREAD_CPU_USAGE_UNAVAILABLE, + threadCpuUserMicros: null, + }, + }); + }); + + it('preserves work metrics and the business result when timer scheduling throws', async () => { + const harness = createFakeRuntime({ + throwScheduleTimeout: true, + }); + const capture = startWorkerPerformanceCapture({ + enabled: true, + runtime: harness.runtime, + }); + await armWorkerPerformanceCapture(capture); + + const execution = await executeWithWorkerPerformanceCapture( + capture, + async () => 'business-result' + ); + + expect(execution).toMatchObject({ + error: null, + result: 'business-result', + success: true, + performance: { + eventLoopDelay: null, + eventLoopDelayUnavailableReason: + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_ARM_TIMEOUT, + eventLoopUtilization: 0.75, + threadCpuSystemMicros: 30, + threadCpuUserMicros: 80, + }, + }); + }); + + it.each([ + { + label: 'count getter during finish', + options: { + throwHistogramCountAtRead: 3, + }, + reason: WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_CAPTURE_UNAVAILABLE, + }, + { + label: 'metric getter after flush', + options: { + throwHistogramMetric: true, + }, + reason: WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_INVALID, + }, + ])( + 'preserves the business result when the histogram $label throws', + async ({ options, reason }) => { + const harness = createFakeRuntime(options); + const capture = startWorkerPerformanceCapture({ + enabled: true, + runtime: harness.runtime, + }); + await armWorkerPerformanceCapture(capture); + + const execution = await executeWithWorkerPerformanceCapture( + capture, + async () => 'business-result' + ); + + expect(execution).toMatchObject({ + error: null, + result: 'business-result', + success: true, + performance: { + eventLoopDelay: null, + eventLoopDelayUnavailableReason: reason, + eventLoopUtilization: 0.75, + threadCpuSystemMicros: 30, + threadCpuUserMicros: 80, + }, + }); + } + ); + + it('keeps the business result and rejects delay metrics when histogram disable throws', async () => { + const harness = createFakeRuntime({ + throwHistogramDisable: true, + }); + const capture = startWorkerPerformanceCapture({ + enabled: true, + runtime: harness.runtime, + }); + await armWorkerPerformanceCapture(capture); + + const execution = await executeWithWorkerPerformanceCapture( + capture, + async () => 'business-result' + ); + + expect(execution).toMatchObject({ + error: null, + result: 'business-result', + success: true, + performance: { + eventLoopDelay: null, + eventLoopDelayUnavailableReason: + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_CAPTURE_UNAVAILABLE, + eventLoopUtilization: 0.75, + histogramFlushedEpochMs: null, + threadCpuSystemMicros: 30, + threadCpuUserMicros: 80, + }, }); - expect(result?.eventLoopUtilization).toBeGreaterThanOrEqual(0); - expect(result?.eventLoopDelay.maxMs).toBeGreaterThan(10); }); }); diff --git a/apps/electron-backend/src/app/workers/worker-performance-capture.test-harness.ts b/apps/electron-backend/src/app/workers/worker-performance-capture.test-harness.ts new file mode 100644 index 000000000..54517e637 --- /dev/null +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.test-harness.ts @@ -0,0 +1,158 @@ +import type { + WorkerEventLoopDelayHistogram, + WorkerPerformanceCaptureRuntime, +} from './worker-performance-capture'; + +interface FakeRuntimeOptions { + armHistogram?: boolean; + flushHistogram?: boolean; + histogramDisableResult?: boolean; + stallMonotonicClock?: boolean; + threadCpuAvailable?: boolean; + throwBoundaryCallbacks?: boolean; + throwHistogramDisable?: boolean; + throwHistogramCountAtRead?: number; + throwHistogramMetric?: boolean; + throwScheduleTimeout?: boolean; +} + +export interface FakeRuntimeHarness { + histogram: WorkerEventLoopDelayHistogram; + histogramLifecycle: { + disableCalls: number; + enableCalls: number; + }; + lifecycle: string[]; + runtime: WorkerPerformanceCaptureRuntime; + scheduledTimeouts: number[]; +} + +export function createFakeRuntime( + options: FakeRuntimeOptions = {} +): FakeRuntimeHarness { + const lifecycle: string[] = []; + const scheduledTimeouts: number[] = []; + let epochIndex = 0; + let monotonicMs = 0; + let timeoutCount = 0; + let cpuIndex = 0; + let eluIndex = 0; + let histogramCount = 0; + let histogramCountReads = 0; + const histogramLifecycle = { + disableCalls: 0, + enableCalls: 0, + }; + const epochValues = [100, 110, 140, 145]; + const cpuValues = [ + { system: 50, user: 100 }, + { system: 80, user: 180 }, + ]; + const eluValues = [ + { active: 10, idle: 20, utilization: 1 / 3 }, + { active: 40, idle: 30, utilization: 4 / 7 }, + ]; + const histogram = { + get count() { + histogramCountReads += 1; + if (histogramCountReads === options.throwHistogramCountAtRead) { + throw new Error('histogram count unavailable'); + } + return histogramCount; + }, + disable: () => { + histogramLifecycle.disableCalls += 1; + if (options.throwHistogramDisable === true) { + throw new Error('histogram disable unavailable'); + } + return options.histogramDisableResult ?? true; + }, + enable: () => { + histogramLifecycle.enableCalls += 1; + return true; + }, + get max() { + if (options.throwHistogramMetric === true) { + throw new Error('histogram max unavailable'); + } + return 24_000_000; + }, + percentile: (percentile: number) => { + if (options.throwHistogramMetric === true) { + throw new Error('histogram percentile unavailable'); + } + if (percentile === 95) { + return 18_000_000; + } + if (percentile === 99) { + return 22_000_000; + } + return 0; + }, + } satisfies WorkerEventLoopDelayHistogram; + const runtime: WorkerPerformanceCaptureRuntime = { + createEventLoopDelayHistogram: () => { + lifecycle.push('histogram:create'); + return histogram; + }, + readEpochMs: () => { + const value = epochValues[epochIndex] ?? 145 + epochIndex; + epochIndex += 1; + lifecycle.push(`epoch:${value}`); + return value; + }, + readEventLoopUtilization: () => { + if (options.throwBoundaryCallbacks === true) { + throw new Error('ELU callback unavailable'); + } + const value = eluValues[eluIndex] ?? eluValues.at(-1)!; + eluIndex += 1; + lifecycle.push(`elu:${eluIndex}`); + return value; + }, + readMonotonicMs: () => monotonicMs, + readThreadCpuUsage: + options.threadCpuAvailable === false + ? null + : () => { + if (options.throwBoundaryCallbacks === true) { + throw new Error('thread CPU callback unavailable'); + } + const value = cpuValues[cpuIndex] ?? cpuValues.at(-1)!; + cpuIndex += 1; + lifecycle.push(`cpu:${cpuIndex}`); + return value; + }, + scheduleTimeout: (callback, delayMs) => { + lifecycle.push(`timeout:${delayMs}`); + scheduledTimeouts.push(delayMs); + if (options.throwScheduleTimeout === true) { + throw new Error('timer callback unavailable'); + } + if (options.stallMonotonicClock !== true) { + monotonicMs += delayMs; + } else if (timeoutCount >= 55) { + throw new Error('test scheduler fail-safe'); + } + timeoutCount += 1; + if (timeoutCount === 1 && options.armHistogram !== false) { + histogramCount += 1; + } else if ( + timeoutCount > 1 && + options.armHistogram !== false && + options.flushHistogram !== false + ) { + histogramCount += 1; + } + callback(); + }, + }; + + return { + histogram, + histogramLifecycle, + lifecycle, + runtime, + scheduledTimeouts, + }; +} diff --git a/apps/electron-backend/src/app/workers/worker-performance-capture.ts b/apps/electron-backend/src/app/workers/worker-performance-capture.ts index db86f234d..a29d97f0a 100644 --- a/apps/electron-backend/src/app/workers/worker-performance-capture.ts +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.ts @@ -1,47 +1,119 @@ import { - monitorEventLoopDelay, - performance, - type IntervalHistogram, -} from 'node:perf_hooks'; + WORKER_PERFORMANCE_INVALID_REASON, + WORKER_PERFORMANCE_UNAVAILABLE_REASON, + type WorkerPerformanceCapture, + type WorkerPerformanceCaptureOptions, + type WorkerPerformanceCaptureResult, + type WorkerPerformanceExecutionResult, +} from './worker-performance-capture.model'; +import { + disableWorkerPerformanceHistogram, + markEventLoopDelayUnavailable, + waitForHistogramCondition, +} from './worker-performance-capture.histogram'; +import { + beginWorkerPerformanceCapture, + createWorkerPerformanceCaptureResult, + finishWorkerPerformanceMetricBoundaries, +} from './worker-performance-capture.metrics'; +import { + DEFAULT_WORKER_PERFORMANCE_RUNTIME, + safeReadEpochMs, + WORKER_PROFILING_ENV, +} from './worker-performance-capture.runtime'; -const WORKER_PROFILING_ENV = 'IPTVNATOR_PERF_WORKER_PROFILING'; +export { + WORKER_PERFORMANCE_INVALID_REASON, + WORKER_PERFORMANCE_UNAVAILABLE_REASON, +} from './worker-performance-capture.model'; +export type { + WorkerEventLoopDelayHistogram, + WorkerEventLoopDelayMetrics, + WorkerPerformanceCapture, + WorkerPerformanceCaptureOptions, + WorkerPerformanceCaptureResult, + WorkerPerformanceCaptureRuntime, + WorkerPerformanceExecutionResult, + WorkerPerformanceInvalidReason, + WorkerPerformanceUnavailableReason, +} from './worker-performance-capture.model'; -export interface WorkerPerformanceCaptureResult { - eventLoopDelay: { - maxMs: number; - p95Ms: number; - p99Ms: number; - }; - eventLoopUtilization: number; -} - -interface WorkerPerformanceCapture { - eventLoopDelay: IntervalHistogram; - eventLoopUtilizationStart: ReturnType< - typeof performance.eventLoopUtilization - >; -} - -export function startWorkerPerformanceCapture(): WorkerPerformanceCapture | null { - if (process.env[WORKER_PROFILING_ENV] !== '1') { +export function startWorkerPerformanceCapture( + options: WorkerPerformanceCaptureOptions = {} +): WorkerPerformanceCapture | null { + const enabled = + options.enabled ?? process.env[WORKER_PROFILING_ENV] === '1'; + if (!enabled) { return null; } - const eventLoopDelay = monitorEventLoopDelay({ resolution: 1 }); - eventLoopDelay.enable(); - return { - eventLoopDelay, - eventLoopUtilizationStart: performance.eventLoopUtilization(), + const runtime = options.runtime ?? DEFAULT_WORKER_PERFORMANCE_RUNTIME; + const capture: WorkerPerformanceCapture = { + eventLoopDelay: null, + eventLoopDelayUnavailableReason: null, + eventLoopUtilizationEnd: null, + eventLoopUtilizationStart: null, + eventLoopUtilizationUnavailableReason: null, + histogramDisabled: false, + histogramFlushedEpochMs: null, + invalidReason: null, + requestReceivedEpochMs: safeReadEpochMs(runtime), + runtime, + threadCpuEnd: null, + threadCpuStart: null, + threadCpuUnavailableReason: + runtime.readThreadCpuUsage === null + ? WORKER_PERFORMANCE_UNAVAILABLE_REASON.THREAD_CPU_USAGE_UNAVAILABLE + : null, + workEndedEpochMs: null, + workStartedEpochMs: null, }; + + try { + capture.eventLoopDelay = runtime.createEventLoopDelayHistogram(); + capture.eventLoopDelay.enable(); + } catch { + capture.eventLoopDelay = null; + capture.eventLoopDelayUnavailableReason = + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_CAPTURE_UNAVAILABLE; + } + + return capture; } export async function armWorkerPerformanceCapture( capture: WorkerPerformanceCapture | null ): Promise { - if (capture) { - await new Promise((resolvePromise) => - setTimeout(resolvePromise, 2) + if (!capture) { + return; + } + + try { + if ( + capture.invalidReason !== null || + capture.eventLoopDelayUnavailableReason !== null + ) { + return; + } + const armed = await waitForHistogramCondition( + capture, + (histogram) => histogram.count > 0 ); + if (!armed && capture.invalidReason === null) { + markEventLoopDelayUnavailable( + capture, + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_ARM_TIMEOUT + ); + } + } catch { + try { + markEventLoopDelayUnavailable( + capture, + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_CAPTURE_UNAVAILABLE + ); + } catch { + // Instrumentation must never prevent business execution. + } } } @@ -52,17 +124,148 @@ export async function finishWorkerPerformanceCapture( return undefined; } - await new Promise((resolvePromise) => setTimeout(resolvePromise, 2)); - capture.eventLoopDelay.disable(); - const eventLoopUtilization = performance.eventLoopUtilization( - capture.eventLoopUtilizationStart + finishWorkerPerformanceMetricBoundaries(capture); + + if ( + capture.invalidReason === null && + capture.eventLoopDelayUnavailableReason === null && + capture.eventLoopDelay + ) { + try { + const workEndedHistogramCount = capture.eventLoopDelay.count; + const flushed = await waitForHistogramCondition( + capture, + (histogram) => histogram.count > workEndedHistogramCount + ); + if (flushed) { + const disabled = disableWorkerPerformanceHistogram(capture); + if (disabled) { + capture.histogramFlushedEpochMs = safeReadEpochMs( + capture.runtime + ); + } else { + markEventLoopDelayUnavailable( + capture, + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_CAPTURE_UNAVAILABLE + ); + } + } else if ( + capture.invalidReason === null && + capture.eventLoopDelayUnavailableReason === null + ) { + markEventLoopDelayUnavailable( + capture, + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_FLUSH_TIMEOUT + ); + } + } catch { + markEventLoopDelayUnavailable( + capture, + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_CAPTURE_UNAVAILABLE + ); + } + } else { + disableWorkerPerformanceHistogram(capture); + } + + return createWorkerPerformanceCaptureResult(capture); +} + +export async function executeWithWorkerPerformanceCapture( + capture: WorkerPerformanceCapture | null, + execute: () => Promise, + finish: ( + capture: WorkerPerformanceCapture | null + ) => Promise< + WorkerPerformanceCaptureResult | undefined + > = finishWorkerPerformanceCapture +): Promise> { + try { + beginWorkerPerformanceCapture(capture); + } catch { + // Instrumentation setup must never prevent business execution. + } + let result: TResult; + try { + result = await execute(); + } catch (error) { + const performance = await safelyFinishWorkerPerformanceCapture( + capture, + finish + ); + return { + error, + performance, + result: undefined, + success: false, + }; + } + + const performance = await safelyFinishWorkerPerformanceCapture( + capture, + finish ); return { - eventLoopDelay: { - maxMs: capture.eventLoopDelay.max / 1e6, - p95Ms: capture.eventLoopDelay.percentile(95) / 1e6, - p99Ms: capture.eventLoopDelay.percentile(99) / 1e6, - }, - eventLoopUtilization: eventLoopUtilization.utilization, + error: null, + performance, + result, + success: true, }; } + +async function safelyFinishWorkerPerformanceCapture( + capture: WorkerPerformanceCapture | null, + finish: ( + capture: WorkerPerformanceCapture | null + ) => Promise +): Promise { + try { + return await finish(capture); + } catch { + if (!capture) { + return undefined; + } + try { + if (capture.workEndedEpochMs === null) { + finishWorkerPerformanceMetricBoundaries(capture); + } + markEventLoopDelayUnavailable( + capture, + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_CAPTURE_UNAVAILABLE + ); + return createWorkerPerformanceCaptureResult(capture); + } catch { + return undefined; + } + } +} + +export function registerDatabaseWorkerPerformanceCapture( + activeCaptures: Set, + capture: WorkerPerformanceCapture | null +): void { + if (!capture) { + return; + } + + if (activeCaptures.size > 0) { + for (const activeCapture of activeCaptures) { + activeCapture.invalidReason = + WORKER_PERFORMANCE_INVALID_REASON.OVERLAPPING_DATABASE_WORKER_REQUESTS; + disableWorkerPerformanceHistogram(activeCapture); + } + capture.invalidReason = + WORKER_PERFORMANCE_INVALID_REASON.OVERLAPPING_DATABASE_WORKER_REQUESTS; + disableWorkerPerformanceHistogram(capture); + } + activeCaptures.add(capture); +} + +export function releaseDatabaseWorkerPerformanceCapture( + activeCaptures: Set, + capture: WorkerPerformanceCapture | null +): void { + if (capture) { + activeCaptures.delete(capture); + } +} diff --git a/apps/electron-backend/src/app/workers/worker-performance-integration.spec.ts b/apps/electron-backend/src/app/workers/worker-performance-integration.spec.ts new file mode 100644 index 000000000..e13a11f29 --- /dev/null +++ b/apps/electron-backend/src/app/workers/worker-performance-integration.spec.ts @@ -0,0 +1,118 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { + WORKER_PERFORMANCE_UNAVAILABLE_REASON, + armWorkerPerformanceCapture, + executeWithWorkerPerformanceCapture, + startWorkerPerformanceCapture, +} from './worker-performance-capture'; +import { createFakeRuntime } from './worker-performance-capture.test-harness'; + +function readWorkerSource(filename: string): string { + return readFileSync( + join(process.cwd(), 'apps/electron-backend/src/app/workers', filename), + 'utf8' + ); +} + +describe('worker performance integration order', () => { + it('profiles overlapping database requests without serializing their execution', () => { + const source = readWorkerSource('database.worker.ts'); + const handler = source.slice( + source.lastIndexOf("parentPort.on('message'") + ); + + expect(handler).toContain('registerDatabaseWorkerPerformanceCapture'); + expect(handler).toContain('executeWithWorkerPerformanceCapture'); + expect(handler).toContain('releaseDatabaseWorkerPerformanceCapture'); + expect(handler).toContain('finally'); + expect(handler).not.toContain('finishWorkerPerformanceCapture'); + expect( + handler.indexOf('executeWithWorkerPerformanceCapture') + ).toBeLessThan(handler.indexOf('console.error')); + expect( + handler.indexOf('executeWithWorkerPerformanceCapture') + ).toBeLessThan(handler.indexOf('postMessage')); + expect(handler).not.toContain('await previous'); + expect(handler).not.toContain('requestQueue'); + }); + + it('finishes playlist profiling before cancellation/error events and response serialization', () => { + const source = readWorkerSource('playlist-refresh.worker.ts'); + const handler = source.slice(source.lastIndexOf('parentPort.on(')); + const executionIndex = handler.indexOf( + 'executeWithWorkerPerformanceCapture' + ); + + expect(executionIndex).toBeGreaterThanOrEqual(0); + expect(handler).not.toContain('finishWorkerPerformanceCapture'); + expect(executionIndex).toBeLessThan( + handler.indexOf('emitEvent', executionIndex) + ); + expect(executionIndex).toBeLessThan( + handler.indexOf('serializeError', executionIndex) + ); + expect(executionIndex).toBeLessThan( + handler.indexOf('postMessage', executionIndex) + ); + }); + + it('preserves success and business errors when the finish seam throws once', async () => { + const successHarness = createFakeRuntime(); + const successCapture = startWorkerPerformanceCapture({ + enabled: true, + runtime: successHarness.runtime, + }); + await armWorkerPerformanceCapture(successCapture); + const failingSuccessFinish = jest.fn(async () => { + throw new Error('finish failed'); + }); + + const success = await executeWithWorkerPerformanceCapture( + successCapture, + async () => 'business-result', + failingSuccessFinish + ); + + expect(success).toMatchObject({ + error: null, + result: 'business-result', + success: true, + performance: { + eventLoopDelay: null, + eventLoopDelayUnavailableReason: + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_CAPTURE_UNAVAILABLE, + eventLoopUtilization: 0.75, + threadCpuSystemMicros: 30, + threadCpuUserMicros: 80, + }, + }); + expect(failingSuccessFinish).toHaveBeenCalledTimes(1); + + const errorHarness = createFakeRuntime(); + const errorCapture = startWorkerPerformanceCapture({ + enabled: true, + runtime: errorHarness.runtime, + }); + await armWorkerPerformanceCapture(errorCapture); + const businessError = new Error('business failed'); + const failingErrorFinish = jest.fn(async () => { + throw new Error('finish failed'); + }); + + const failure = await executeWithWorkerPerformanceCapture( + errorCapture, + async () => { + throw businessError; + }, + failingErrorFinish + ); + + expect(failure).toMatchObject({ + error: businessError, + result: undefined, + success: false, + }); + expect(failingErrorFinish).toHaveBeenCalledTimes(1); + }); +}); diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index 48a4a468b..855da02a3 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -121,11 +121,27 @@ before/after claims. The target reserves and verifies CDP port 9222, freezes renderer long-task, frame-gap, and heartbeat probes before forced post-GC heap collection, and -enables opt-in worker profiling. Worker event-loop delay is read from a -request-scoped `node:perf_hooks` capture; a worker terminated before it can flush -the capture reports the metric as unavailable rather than zero. Diagnostic CPU -profiles, heap snapshots, and Chromium traces are excluded from the five-run -headline distributions. +enables opt-in worker profiling. Each worker response retains a raw +request-scoped record containing request/operation identity, received/work/flush +timestamps, thread CPU, event-loop utilization, event-loop delay, and fixed +unavailability or invalid reasons. Missing or malformed profiling metadata +still produces an outcome with its request identity, nullable metrics, and a +fixed capture-unavailable reason. A worker terminated before it can flush the +capture reports the metric as unavailable rather than zero. + +The harness excludes workers created by pre-capture seed setup through an exact +capture-generation marker. Since the production M3U database payload +deliberately omits the refresh operation ID, the benchmark correlates only a +valid preload `start` marker to the exact database operation and playlist. A +missing or ambiguous marker leaves the raw operation ID `null` with a fixed +reason; it does not infer identity from timing alone. + +Headline worker fields named `*WorkerRequest*` are distributions of individual +request metrics. In particular, request p95/p99 distributions are not presented +as an operation-wide or process-wide percentile, and database request +percentiles are never collapsed with `Math.max`. Diagnostic CPU profiles, heap +snapshots, and Chromium traces are excluded from the five-run headline +distributions. ### Reporting The Auto-Update Result diff --git a/docs/architecture/sqlite-db-worker.md b/docs/architecture/sqlite-db-worker.md index 0e2a3705b..52bdbb860 100644 --- a/docs/architecture/sqlite-db-worker.md +++ b/docs/architecture/sqlite-db-worker.md @@ -118,6 +118,35 @@ The worker contract lives in 3. `DbWorkerEventMessage` 4. `DbOperationEvent` +### Opt-in request performance capture + +`IPTVNATOR_PERF_WORKER_PROFILING=1` adds development/test-only performance +metadata to each database-worker response. It is disabled by default and must +stay disabled for production launches. + +Each enabled request gets a fresh event-loop-delay histogram and records: + +- `requestReceivedEpochMs`, `workStartedEpochMs`, `workEndedEpochMs`, and + `histogramFlushedEpochMs` +- worker-thread CPU user/system microseconds from `process.threadCpuUsage()` +- event-loop utilization across the exact work interval +- event-loop-delay max/p95/p99 from the request's own histogram +- fixed invalid or unavailable reasons whenever a metric cannot be attributed + +Histogram arming waits until the histogram has a sample; flushing waits for its +sample count to advance after work ends. Both waits use condition-based timer +polling. Each wait stops after 50 ms of observed monotonic time or its bounded +poll count; arming and flushing have separate caps, and timer scheduling may +overshoot wall-clock time. A timeout or profiling API failure never replaces +the business response: timestamps and any independently available CPU/ELU +metrics remain valid, while event-loop delay is `null` with a fixed reason. + +The long-lived database worker still executes concurrent requests without a +profiling queue. If captures overlap, every overlapping response carries +`invalidReason: "overlapping-database-worker-requests"` and all attributable +CPU, ELU, and event-loop-delay values are `null`. This avoids assigning shared +worker activity to one request while preserving normal worker concurrency. + ### Progress event contract The worker now emits request-scoped events with: From 8bafd629321a11506c0320c374fedbefdd3808e6 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 26 Jul 2026 22:49:36 +0200 Subject: [PATCH 7/9] fix(perf): benchmark the performance build --- apps/electron-backend-e2e/project.json | 2 +- .../src/performance/performance-build-config.spec.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/electron-backend-e2e/project.json b/apps/electron-backend-e2e/project.json index 51ed910a8..9c1f45538 100644 --- a/apps/electron-backend-e2e/project.json +++ b/apps/electron-backend-e2e/project.json @@ -20,7 +20,7 @@ } }, "benchmark-m3u-refresh-cancellation": { - "dependsOn": ["electron-backend:build-e2e"], + "dependsOn": ["electron-backend:build-performance"], "executor": "nx:run-commands", "cache": false, "parallelism": false, diff --git a/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts b/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts index 94a64095a..dd2ddf7e6 100644 --- a/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts +++ b/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts @@ -210,10 +210,10 @@ test('the Electron performance wrapper owns build dependencies before its profil ); }); -test('the cancellation benchmark keeps its existing E2E build dependency', () => { +test('the cancellation benchmark uses the production-equivalent performance build', () => { const target = e2eProject.targets['benchmark-m3u-refresh-cancellation']; - assert.deepEqual(target.dependsOn, ['electron-backend:build-e2e']); + assert.deepEqual(target.dependsOn, ['electron-backend:build-performance']); }); test('the cancellation benchmark command is pinned to its Playwright test file', () => { From df7eee7e522789cc6f558141260964ddbaa9f7a0 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 26 Jul 2026 23:14:57 +0200 Subject: [PATCH 8/9] fix(perf): exclude profiler flush from cancel latency --- .../app/workers/playlist-refresh.worker.ts | 30 ++++++++--------- .../worker-performance-cancellation.spec.ts | 33 +++++++++++++++++-- .../worker-performance-integration.spec.ts | 5 +-- 3 files changed, 46 insertions(+), 22 deletions(-) diff --git a/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts b/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts index 3bd0059ba..f6062de87 100644 --- a/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts +++ b/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts @@ -178,6 +178,20 @@ async function executeRefresh( checkpoint(payload); return playlist; + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + emitEvent(payload, { + status: 'cancelled', + phase: 'parsing', + }); + } else { + emitEvent(payload, { + status: 'error', + phase: payload.url ? 'fetching' : 'reading-file', + error: error instanceof Error ? error.message : String(error), + }); + } + throw error; } finally { releaseActiveRefresh(payload.operationId, activeRefresh); } @@ -219,22 +233,6 @@ parentPort.on( }); } else { const error = execution.error; - if (error instanceof Error && error.name === 'AbortError') { - emitEvent(payload, { - status: 'cancelled', - phase: 'parsing', - }); - } else { - emitEvent(payload, { - status: 'error', - phase: payload.url ? 'fetching' : 'reading-file', - error: - error instanceof Error - ? error.message - : String(error), - }); - } - postMessage({ type: 'response', success: false, diff --git a/apps/electron-backend/src/app/workers/worker-performance-cancellation.spec.ts b/apps/electron-backend/src/app/workers/worker-performance-cancellation.spec.ts index eef0171f3..4cdd550b7 100644 --- a/apps/electron-backend/src/app/workers/worker-performance-cancellation.spec.ts +++ b/apps/electron-backend/src/app/workers/worker-performance-cancellation.spec.ts @@ -50,7 +50,13 @@ function createParentPortHarness() { }; } -function mockPerformanceCapture(armGate: ArmGate): void { +function mockPerformanceCapture( + armGate: ArmGate, + failureGates?: { + readonly observed: ArmGate; + readonly profilingFinished: ArmGate; + } +): void { jest.doMock('./worker-performance-capture', () => ({ armWorkerPerformanceCapture: jest.fn(() => armGate.promise), executeWithWorkerPerformanceCapture: jest.fn( @@ -63,6 +69,8 @@ function mockPerformanceCapture(armGate: ArmGate): void { success: true, }; } catch (error) { + failureGates?.observed.release(); + await failureGates?.profilingFinished.promise; return { error, performance: undefined, @@ -86,12 +94,17 @@ describe('worker cancellation while performance capture arms', () => { it('cancels playlist work before file access when cancel arrives during arming', async () => { const armGate = createArmGate(); + const profilingFinished = createArmGate(); + const failureObserved = createArmGate(); const port = createParentPortHarness< PlaylistRefreshWorkerIncomingMessage, PlaylistRefreshWorkerMessage >(); const readFile = jest.fn().mockResolvedValue('#EXTM3U'); - mockPerformanceCapture(armGate); + mockPerformanceCapture(armGate, { + observed: failureObserved, + profilingFinished, + }); jest.doMock('worker_threads', () => ({ parentPort: port.parentPort, })); @@ -122,9 +135,25 @@ describe('worker cancellation while performance capture arms', () => { operationId: 'refresh-1', }); armGate.release(); + await failureObserved.promise; + + const cancelledBeforeProfilingFinished = + port.postMessage.mock.calls.some( + ([message]) => + message.type === 'event' && + message.event.status === 'cancelled' + ); + const responseBeforeProfilingFinished = + port.postMessage.mock.calls.some( + ([message]) => message.type === 'response' + ); + + profilingFinished.release(); await requestPromise; expect(readFile).not.toHaveBeenCalled(); + expect(cancelledBeforeProfilingFinished).toBe(true); + expect(responseBeforeProfilingFinished).toBe(false); expect(port.postMessage).toHaveBeenCalledWith( expect.objectContaining({ event: expect.objectContaining({ diff --git a/apps/electron-backend/src/app/workers/worker-performance-integration.spec.ts b/apps/electron-backend/src/app/workers/worker-performance-integration.spec.ts index e13a11f29..6ecaac4bd 100644 --- a/apps/electron-backend/src/app/workers/worker-performance-integration.spec.ts +++ b/apps/electron-backend/src/app/workers/worker-performance-integration.spec.ts @@ -37,7 +37,7 @@ describe('worker performance integration order', () => { expect(handler).not.toContain('requestQueue'); }); - it('finishes playlist profiling before cancellation/error events and response serialization', () => { + it('keeps playlist response serialization after profiling completion', () => { const source = readWorkerSource('playlist-refresh.worker.ts'); const handler = source.slice(source.lastIndexOf('parentPort.on(')); const executionIndex = handler.indexOf( @@ -46,9 +46,6 @@ describe('worker performance integration order', () => { expect(executionIndex).toBeGreaterThanOrEqual(0); expect(handler).not.toContain('finishWorkerPerformanceCapture'); - expect(executionIndex).toBeLessThan( - handler.indexOf('emitEvent', executionIndex) - ); expect(executionIndex).toBeLessThan( handler.indexOf('serializeError', executionIndex) ); From a3a8f6e90ccbdd49dccabfc6adde897c5b4c2569 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 26 Jul 2026 23:25:09 +0200 Subject: [PATCH 9/9] fix(perf): optimize benchmark worker builds --- .../performance-build-config.spec.ts | 23 ++++++++++++++++++- apps/electron-backend/build-worker.js | 12 +++++++--- apps/electron-backend/project.json | 9 +++++++- docs/architecture/m3u-playlist-module.md | 4 +++- 4 files changed, 42 insertions(+), 6 deletions(-) diff --git a/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts b/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts index dd2ddf7e6..c854a5568 100644 --- a/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts +++ b/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts @@ -197,7 +197,7 @@ test('the Electron performance wrapper owns build dependencies before its profil assert.ok(target, 'electron-backend must define build-performance'); assert.equal(target.executor, 'nx:run-commands'); assert.deepEqual(target.dependsOn, [ - 'electron-backend:build-worker', + 'electron-backend:build-worker-performance', 'electron-backend:build-embedded-mpv', { projects: ['web', 'remote-control-web'], @@ -210,6 +210,27 @@ test('the Electron performance wrapper owns build dependencies before its profil ); }); +test('the Electron performance wrapper builds optimized source-mapped workers', () => { + const target = electronProject.targets['build-worker-performance']; + const source = readFileSync( + join(workspaceRoot, 'apps/electron-backend/build-worker.js'), + 'utf8' + ); + + assert.ok(target, 'electron-backend must define build-worker-performance'); + assert.equal(target.executor, 'nx:run-commands'); + assert.equal( + target.options?.['command'], + 'node apps/electron-backend/build-worker.js --performance' + ); + assert.match( + source, + /const isPerformance = process\.argv\.includes\('--performance'\)/ + ); + assert.match(source, /minify: isProduction \|\| isPerformance/); + assert.match(source, /sourcemap: isPerformance \|\| !isProduction/); +}); + test('the cancellation benchmark uses the production-equivalent performance build', () => { const target = e2eProject.targets['benchmark-m3u-refresh-cancellation']; diff --git a/apps/electron-backend/build-worker.js b/apps/electron-backend/build-worker.js index a80817272..649391365 100644 --- a/apps/electron-backend/build-worker.js +++ b/apps/electron-backend/build-worker.js @@ -18,6 +18,12 @@ const nativeModules = [ ]; const isProduction = process.env.NODE_ENV === 'production'; +const isPerformance = process.argv.includes('--performance'); +const buildMode = isPerformance + ? 'performance' + : isProduction + ? 'production' + : 'development'; async function buildWorker() { try { @@ -59,7 +65,7 @@ async function buildWorker() { for (const worker of workers) { console.log( - `Building ${worker.label} with esbuild (${isProduction ? 'production' : 'development'})...` + `Building ${worker.label} with esbuild (${buildMode})...` ); await esbuild.build({ @@ -74,8 +80,8 @@ async function buildWorker() { ...nodeBuiltins, ...nativeModules, ], - sourcemap: !isProduction, - minify: isProduction, + sourcemap: isPerformance || !isProduction, + minify: isProduction || isPerformance, alias: { '@iptvnator/shared/interfaces': path.join( __dirname, diff --git a/apps/electron-backend/project.json b/apps/electron-backend/project.json index 37b1e7e07..6e4107999 100644 --- a/apps/electron-backend/project.json +++ b/apps/electron-backend/project.json @@ -12,6 +12,13 @@ "command": "node apps/electron-backend/build-worker.js" } }, + "build-worker-performance": { + "executor": "nx:run-commands", + "outputs": ["{workspaceRoot}/dist/apps/electron-backend/workers"], + "options": { + "command": "node apps/electron-backend/build-worker.js --performance" + } + }, "build-embedded-mpv": { "executor": "nx:run-commands", "cache": false, @@ -98,7 +105,7 @@ }, "build-performance": { "dependsOn": [ - "electron-backend:build-worker", + "electron-backend:build-worker-performance", "electron-backend:build-embedded-mpv", { "projects": ["web", "remote-control-web"], diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index 855da02a3..09aef8f12 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -121,7 +121,9 @@ before/after claims. The target reserves and verifies CDP port 9222, freezes renderer long-task, frame-gap, and heartbeat probes before forced post-GC heap collection, and -enables opt-in worker profiling. Each worker response retains a raw +builds the Electron main process, renderer, and workers with optimized, +source-mapped performance configurations before enabling opt-in worker +profiling. Each worker response retains a raw request-scoped record containing request/operation identity, received/work/flush timestamps, thread CPU, event-loop utilization, event-loop delay, and fixed unavailability or invalid reasons. Missing or malformed profiling metadata