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;