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