fix(electron): isolate refresh performance correlation

This commit is contained in:
4gray committed 2026-07-26 22:37:52 +02:00
1 parent e3ce60a35e
commit f9e71a6d8d
25 files changed
+728 -402

No files matched your search

@@ -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',
@@ -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'/);
});
@@ -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);
});
});
@@ -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<Settings>;
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<Record<string, unknown>>(
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<Record<string, unknown>>(
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([]);
});
});
@@ -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],
]);
});
});
@@ -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),
@@ -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.
}
}
@@ -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<number, PreloadPerformanceCall>,
sequences: Map<string, PreloadPerformanceSequence>,
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<number, PreloadPerformanceCall>,
sequences: Map<string, PreloadPerformanceSequence>,
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;
}
);
}
@@ -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,
});
});
});
@@ -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,
});
}
);
});
@@ -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,
};
@@ -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,
},
@@ -14,6 +14,28 @@ import {
type PreloadPerformanceSequence,
} from './preload-performance-correlation.model';
function rememberUncorrelatedCall(
calls: Map<number, PreloadPerformanceCall>,
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<number, PreloadPerformanceCall>,
sequences: Map<string, PreloadPerformanceSequence>,
@@ -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<number, PreloadPerformanceCall>,
sequences: Map<string, PreloadPerformanceSequence>,
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);
}
@@ -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(
+8 -1
View File
@@ -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;
+25 -21
View File
@@ -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
);
}
})
)
)
);
},
@@ -0,0 +1,29 @@
import type { Playlist } from '@iptvnator/shared/interfaces';
interface PlaylistUpdateAction {
operationId?: string;
playlist: Playlist;
playlistId: string;
}
interface PlaylistUpdater<TResult> {
updatePlaylist: (
playlistId: string,
playlist: Playlist,
operationId?: string
) => TResult;
}
export function persistPlaylistUpdate<TResult>(
playlistsService: PlaylistUpdater<TResult>,
action: PlaylistUpdateAction
): TResult {
return playlistsService.updatePlaylist(
action.playlistId,
{
...action.playlist,
_id: action.playlistId,
},
action.operationId
);
}
@@ -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'
);
});
});
@@ -630,6 +630,7 @@ describe('PlaylistRefreshActionService', () => {
playlist: refreshedPlaylist,
playlistId: item._id,
refreshEpg: true,
operationId: 'playlist-refresh-op',
})
);
});
@@ -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,
})
);
@@ -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',
})
);
});
});
@@ -463,6 +463,7 @@ export class RecentPlaylistsComponent {
},
playlistId: item._id,
refreshEpg: true,
operationId,
})
);
@@ -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')
+36 -13
View File
@@ -52,7 +52,10 @@ type PlaylistRawItem = {
type PlaylistStorageElectronApi = {
dbDeleteAllPlaylists: () => Promise<unknown>;
dbDeletePlaylist: (playlistId: string) => Promise<unknown>;
dbGetAppPlaylist: (playlistId: string) => Promise<Playlist | null>;
dbGetAppPlaylist: (
playlistId: string,
operationId?: string
) => Promise<Playlist | null>;
dbGetAppPlaylistFavoriteChannels?: (
playlistId: string
) => Promise<M3uFavoriteChannel[]>;
@@ -60,7 +63,10 @@ type PlaylistStorageElectronApi = {
dbGetAppPlaylists: () => Promise<Playlist[]>;
dbGetAppState: (key: string) => Promise<string | null>;
dbSetAppState: (key: string, value: string) => Promise<unknown>;
dbUpsertAppPlaylist: (playlist: Playlist) => Promise<unknown>;
dbUpsertAppPlaylist: (
playlist: Playlist,
operationId?: string
) => Promise<unknown>;
dbUpsertAppPlaylists: (playlists: Playlist[]) => Promise<unknown>;
};
@@ -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);
@@ -684,13 +684,21 @@ export interface ElectronBridgeApi {
dbGetPlaylist: (
playlistId: string
) => Promise<ElectronBridgePlaylistRow | null>;
dbUpsertAppPlaylist: (playlist: Playlist) => Promise<ElectronBridgeResult>;
dbUpsertAppPlaylist: (
playlist: Playlist,
/** Instrumentation-only; never persisted or sent to main/worker IPC. */
operationId?: string
) => Promise<ElectronBridgeResult>;
dbUpsertAppPlaylists: (
playlists: Playlist[]
) => Promise<ElectronBridgeCountResult>;
dbGetAppPlaylists: () => Promise<Playlist[]>;
dbGetAppPlaylistMetas: () => Promise<Playlist[]>;
dbGetAppPlaylist: (playlistId: string) => Promise<Playlist | null>;
dbGetAppPlaylist: (
playlistId: string,
/** Instrumentation-only; never persisted or sent to main/worker IPC. */
operationId?: string
) => Promise<Playlist | null>;
dbGetAppPlaylistFavoriteChannels: (
playlistId: string
) => Promise<M3uFavoriteChannel[]>;