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; +}