mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 21:56:18 -08:00
test(performance): add end-to-end Xtream benchmark harness (#1300)
* docs(performance): plan Xtream benchmark * feat(xtream-mock-server): add deterministic 100k fixture * style(xtream-mock-server): apply repository formatting * fix(xtream-mock-server): harden performance fixture data * feat(xtream-mock-server): add performance control plane * docs(performance): correct Xtream capture plan * fix(xtream-mock-server): harden performance controls * fix(xtream-mock-server): harden control lifecycle * feat(performance): add Xtream preload markers * feat(performance): trace Xtream main phases * feat(performance): mark Xtream store publications * feat(performance): trace Xtream database phases * feat(performance): trace Xtream delete cancellation * feat(performance): capture Xtream phase attribution * feat(performance): mark Sources Xtream refresh * test(performance): define Xtream benchmark evidence contracts * test(performance): add Xtream benchmark runner * test(performance): surface failure evidence writes * test(performance): align database read clock * test(performance): preserve capture failure contracts
This commit is contained in:
1 parent
5e725a06f3
commit
a2fafcfc08
256 files changed
+43760
-883
No files matched your search
@@ -23,9 +23,26 @@ import {
|
||||
PlaylistMeta,
|
||||
SECURITY_ERROR_PREFIX,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
RENDERER_PERFORMANCE_PHASE_HOOK_KEY,
|
||||
type RendererPerformancePhaseEvent,
|
||||
} from '@iptvnator/shared/logging';
|
||||
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
|
||||
import { PlaylistRefreshActionService } from './playlist-refresh-action.service';
|
||||
|
||||
const performanceHookSymbol = Symbol.for(RENDERER_PERFORMANCE_PHASE_HOOK_KEY);
|
||||
|
||||
function setPerformanceHook(
|
||||
hook: ((event: RendererPerformancePhaseEvent) => void) | null
|
||||
): void {
|
||||
const target = globalThis as unknown as Record<symbol, unknown>;
|
||||
if (hook === null) {
|
||||
delete target[performanceHookSymbol];
|
||||
} else {
|
||||
target[performanceHookSymbol] = hook;
|
||||
}
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
@@ -233,6 +250,7 @@ describe('PlaylistRefreshActionService', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setPerformanceHook(null);
|
||||
jest.restoreAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
@@ -382,6 +400,11 @@ describe('PlaylistRefreshActionService', () => {
|
||||
it('stores Xtream restore data before updating playlist meta and navigating', async () => {
|
||||
const item = createPlaylistMeta();
|
||||
const executionOrder: string[] = [];
|
||||
const performanceEvents: RendererPerformancePhaseEvent[] = [];
|
||||
setPerformanceHook((event) => {
|
||||
performanceEvents.push(event);
|
||||
executionOrder.push(event.boundary);
|
||||
});
|
||||
let confirmPromise: Promise<void> | undefined;
|
||||
const dateNowSpy = jest
|
||||
.spyOn(Date, 'now')
|
||||
@@ -456,7 +479,34 @@ describe('PlaylistRefreshActionService', () => {
|
||||
'xtreams',
|
||||
item._id,
|
||||
]);
|
||||
expect(executionOrder).toEqual(['setItem', 'dispatch', 'navigate']);
|
||||
expect(executionOrder).toEqual([
|
||||
'setItem',
|
||||
'start',
|
||||
'dispatch',
|
||||
'end',
|
||||
'navigate',
|
||||
]);
|
||||
expect(
|
||||
performanceEvents.map(({ boundary, metadata, outcome, phase }) => ({
|
||||
boundary,
|
||||
items: metadata?.items,
|
||||
outcome,
|
||||
phase,
|
||||
}))
|
||||
).toEqual([
|
||||
{
|
||||
boundary: 'start',
|
||||
items: 1,
|
||||
outcome: undefined,
|
||||
phase: 'store.xtream-refresh-meta',
|
||||
},
|
||||
{
|
||||
boundary: 'end',
|
||||
items: 1,
|
||||
outcome: 'success',
|
||||
phase: 'store.xtream-refresh-meta',
|
||||
},
|
||||
]);
|
||||
|
||||
setItemSpy.mockRestore();
|
||||
dateNowSpy.mockRestore();
|
||||
|
||||
@@ -23,6 +23,10 @@ import {
|
||||
PLAYLIST_UPDATE,
|
||||
PlaylistMeta,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
measureRendererPerformancePhase,
|
||||
RENDERER_PERFORMANCE_PHASE,
|
||||
} from '@iptvnator/shared/logging';
|
||||
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
|
||||
|
||||
export interface XtreamRefreshPreparationState {
|
||||
@@ -159,10 +163,15 @@ export class PlaylistRefreshActionService {
|
||||
playbackPositions,
|
||||
});
|
||||
|
||||
this.store.dispatch(
|
||||
PlaylistActions.updatePlaylistMeta({
|
||||
playlist: { ...item, updateDate },
|
||||
})
|
||||
measureRendererPerformancePhase(
|
||||
RENDERER_PERFORMANCE_PHASE.XTREAM_REFRESH_META,
|
||||
() =>
|
||||
this.store.dispatch(
|
||||
PlaylistActions.updatePlaylistMeta({
|
||||
playlist: { ...item, updateDate },
|
||||
})
|
||||
),
|
||||
() => ({ items: 1 })
|
||||
);
|
||||
|
||||
await this.router.navigate([
|
||||
|
||||
+117
-1
@@ -27,8 +27,25 @@ import {
|
||||
SortService,
|
||||
} from '@iptvnator/services';
|
||||
import { PLAYLIST_UPDATE, PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
RENDERER_PERFORMANCE_PHASE_HOOK_KEY,
|
||||
type RendererPerformancePhaseEvent,
|
||||
} from '@iptvnator/shared/logging';
|
||||
import { RecentPlaylistsComponent } from './recent-playlists.component';
|
||||
|
||||
const performanceHookSymbol = Symbol.for(RENDERER_PERFORMANCE_PHASE_HOOK_KEY);
|
||||
|
||||
function setPerformanceHook(
|
||||
hook: ((event: RendererPerformancePhaseEvent) => void) | null
|
||||
): void {
|
||||
const target = globalThis as unknown as Record<symbol, unknown>;
|
||||
if (hook === null) {
|
||||
delete target[performanceHookSymbol];
|
||||
} else {
|
||||
target[performanceHookSymbol] = hook;
|
||||
}
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
@@ -233,11 +250,14 @@ describe('RecentPlaylistsComponent busy state', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setPerformanceHook(null);
|
||||
jest.restoreAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('tracks delete progress and clears the busy row after completion', async () => {
|
||||
const events: RendererPerformancePhaseEvent[] = [];
|
||||
setPerformanceHook((event) => events.push(event));
|
||||
const item = createPlaylistMeta({ _id: 'playlist-delete-1' });
|
||||
const deletion = createDeferred<boolean>();
|
||||
|
||||
@@ -290,9 +310,32 @@ describe('RecentPlaylistsComponent busy state', () => {
|
||||
undefined,
|
||||
{ duration: 2000 }
|
||||
);
|
||||
expect(
|
||||
events.map(({ boundary, metadata, outcome, phase }) => ({
|
||||
boundary,
|
||||
items: metadata?.items,
|
||||
outcome,
|
||||
phase,
|
||||
}))
|
||||
).toEqual([
|
||||
{
|
||||
boundary: 'start',
|
||||
items: 1,
|
||||
outcome: undefined,
|
||||
phase: 'store.xtream-delete-row',
|
||||
},
|
||||
{
|
||||
boundary: 'end',
|
||||
items: 1,
|
||||
outcome: 'success',
|
||||
phase: 'store.xtream-delete-row',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('delegates playlist deletion and updates local UI state after success', async () => {
|
||||
const events: RendererPerformancePhaseEvent[] = [];
|
||||
setPerformanceHook((event) => events.push(event));
|
||||
const item = createPlaylistMeta({
|
||||
_id: 'pwa-playlist-1',
|
||||
serverUrl: undefined,
|
||||
@@ -316,6 +359,43 @@ describe('RecentPlaylistsComponent busy state', () => {
|
||||
undefined,
|
||||
{ duration: 2000 }
|
||||
);
|
||||
expect(events).toEqual([]);
|
||||
});
|
||||
|
||||
it('records dispatch failure and still clears delete-row busy state', async () => {
|
||||
const events: RendererPerformancePhaseEvent[] = [];
|
||||
const item = createPlaylistMeta({ _id: 'dispatch-failure' });
|
||||
const dispatchError = new Error('dispatch failed');
|
||||
setPerformanceHook((event) => events.push(event));
|
||||
(store.dispatch as jest.Mock).mockImplementation(() => {
|
||||
throw dispatchError;
|
||||
});
|
||||
|
||||
await expect(component.removePlaylist(item)).rejects.toBe(
|
||||
dispatchError
|
||||
);
|
||||
|
||||
expect(component.isDeletePending(item._id)).toBe(false);
|
||||
expect(component.getBusyProgress(item._id)).toBeNull();
|
||||
expect(snackBar.open).not.toHaveBeenCalled();
|
||||
expect(
|
||||
events.map(({ boundary, outcome, phase }) => ({
|
||||
boundary,
|
||||
outcome,
|
||||
phase,
|
||||
}))
|
||||
).toEqual([
|
||||
{
|
||||
boundary: 'start',
|
||||
outcome: undefined,
|
||||
phase: 'store.xtream-delete-row',
|
||||
},
|
||||
{
|
||||
boundary: 'end',
|
||||
outcome: 'error',
|
||||
phase: 'store.xtream-delete-row',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('tracks Xtream refresh progress and clears the busy row after abort', async () => {
|
||||
@@ -388,6 +468,11 @@ describe('RecentPlaylistsComponent busy state', () => {
|
||||
const item = createPlaylistMeta({ _id: 'playlist-refresh-success-1' });
|
||||
let confirmPromise: Promise<void> | undefined;
|
||||
const executionOrder: string[] = [];
|
||||
const performanceEvents: RendererPerformancePhaseEvent[] = [];
|
||||
setPerformanceHook((event) => {
|
||||
performanceEvents.push(event);
|
||||
executionOrder.push(event.boundary);
|
||||
});
|
||||
const dateNowSpy = jest
|
||||
.spyOn(Date, 'now')
|
||||
.mockReturnValue(1712145600000);
|
||||
@@ -476,7 +561,38 @@ describe('RecentPlaylistsComponent busy state', () => {
|
||||
'xtreams',
|
||||
item._id,
|
||||
]);
|
||||
expect(executionOrder).toEqual(['setItem', 'dispatch', 'navigate']);
|
||||
expect(executionOrder).toEqual([
|
||||
'setItem',
|
||||
'start',
|
||||
'dispatch',
|
||||
'end',
|
||||
'navigate',
|
||||
]);
|
||||
expect(performanceEvents).toHaveLength(2);
|
||||
expect(performanceEvents[0]?.phaseId).toBe(
|
||||
performanceEvents[1]?.phaseId
|
||||
);
|
||||
expect(
|
||||
performanceEvents.map(({ boundary, metadata, outcome, phase }) => ({
|
||||
boundary,
|
||||
metadata,
|
||||
outcome,
|
||||
phase,
|
||||
}))
|
||||
).toEqual([
|
||||
{
|
||||
boundary: 'start',
|
||||
metadata: { items: 1 },
|
||||
outcome: undefined,
|
||||
phase: 'store.xtream-refresh-meta',
|
||||
},
|
||||
{
|
||||
boundary: 'end',
|
||||
metadata: { items: 1 },
|
||||
outcome: 'success',
|
||||
phase: 'store.xtream-refresh-meta',
|
||||
},
|
||||
]);
|
||||
|
||||
setItemSpy.mockRestore();
|
||||
dateNowSpy.mockRestore();
|
||||
|
||||
@@ -46,6 +46,10 @@ import {
|
||||
PlaylistMeta,
|
||||
PlaylistRefreshEvent,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
measureRendererPerformancePhase,
|
||||
RENDERER_PERFORMANCE_PHASE,
|
||||
} from '@iptvnator/shared/logging';
|
||||
|
||||
import { EmptyStateComponent } from './empty-state/empty-state.component';
|
||||
import type { PlaylistType } from '../add-playlist-menu/playlist-type';
|
||||
@@ -271,9 +275,24 @@ export class RecentPlaylistsComponent {
|
||||
}
|
||||
);
|
||||
if (deleted) {
|
||||
this.store.dispatch(
|
||||
PlaylistActions.removePlaylist({ playlistId: item._id })
|
||||
);
|
||||
if (item.serverUrl) {
|
||||
measureRendererPerformancePhase(
|
||||
RENDERER_PERFORMANCE_PHASE.XTREAM_DELETE_ROW,
|
||||
() =>
|
||||
this.store.dispatch(
|
||||
PlaylistActions.removePlaylist({
|
||||
playlistId: item._id,
|
||||
})
|
||||
),
|
||||
() => ({ items: 1 })
|
||||
);
|
||||
} else {
|
||||
this.store.dispatch(
|
||||
PlaylistActions.removePlaylist({
|
||||
playlistId: item._id,
|
||||
})
|
||||
);
|
||||
}
|
||||
this.snackBar.open(
|
||||
this.translate.instant(
|
||||
'HOME.PLAYLISTS.REMOVE_DIALOG.SUCCESS'
|
||||
@@ -389,10 +408,15 @@ export class RecentPlaylistsComponent {
|
||||
});
|
||||
|
||||
// Update the timestamp in NgRx / IndexedDB
|
||||
this.store.dispatch(
|
||||
PlaylistActions.updatePlaylistMeta({
|
||||
playlist: { ...item, updateDate },
|
||||
})
|
||||
measureRendererPerformancePhase(
|
||||
RENDERER_PERFORMANCE_PHASE.XTREAM_REFRESH_META,
|
||||
() =>
|
||||
this.store.dispatch(
|
||||
PlaylistActions.updatePlaylistMeta({
|
||||
playlist: { ...item, updateDate },
|
||||
})
|
||||
),
|
||||
() => ({ items: 1 })
|
||||
);
|
||||
|
||||
// Navigate to the playlist to trigger re-import
|
||||
|
||||
+150
-12
@@ -1,6 +1,10 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { patchState, signalStore, withMethods, withState } from '@ngrx/signals';
|
||||
import { DatabaseService } from '@iptvnator/services';
|
||||
import {
|
||||
RENDERER_PERFORMANCE_PHASE_HOOK_KEY,
|
||||
type RendererPerformancePhaseEvent,
|
||||
} from '@iptvnator/shared/logging';
|
||||
import {
|
||||
XTREAM_DATA_SOURCE,
|
||||
XtreamPlaylistData,
|
||||
@@ -28,6 +32,18 @@ const PLAYLIST: XtreamPlaylistData = {
|
||||
password: 'secret',
|
||||
type: 'xtream',
|
||||
};
|
||||
const performanceHookSymbol = Symbol.for(RENDERER_PERFORMANCE_PHASE_HOOK_KEY);
|
||||
|
||||
function setPerformanceHook(
|
||||
hook: ((event: RendererPerformancePhaseEvent) => void) | null
|
||||
): void {
|
||||
const target = globalThis as unknown as Record<symbol, unknown>;
|
||||
if (hook === null) {
|
||||
delete target[performanceHookSymbol];
|
||||
} else {
|
||||
target[performanceHookSymbol] = hook;
|
||||
}
|
||||
}
|
||||
|
||||
let checkPortalStatusMock: jest.Mock<Promise<PortalStatusType>, []>;
|
||||
|
||||
@@ -120,14 +136,16 @@ describe('withContent import state', () => {
|
||||
databaseService = {
|
||||
clearXtreamImportCache: jest.fn().mockResolvedValue(true),
|
||||
cancelOperation: jest.fn().mockResolvedValue(true),
|
||||
createOperationId: jest.fn().mockImplementation((prefix?: string) => {
|
||||
if (prefix === 'xtream-import-session') {
|
||||
return 'xtream-import-session';
|
||||
}
|
||||
createOperationId: jest
|
||||
.fn()
|
||||
.mockImplementation((prefix?: string) => {
|
||||
if (prefix === 'xtream-import-session') {
|
||||
return 'xtream-import-session';
|
||||
}
|
||||
|
||||
operationCounter += 1;
|
||||
return `${prefix ?? 'db-op'}-${operationCounter}`;
|
||||
}),
|
||||
operationCounter += 1;
|
||||
return `${prefix ?? 'db-op'}-${operationCounter}`;
|
||||
}),
|
||||
getXtreamImportStatus: jest.fn().mockResolvedValue('completed'),
|
||||
setXtreamImportStatus: jest.fn().mockResolvedValue(true),
|
||||
supportsDbOperationCancellation: jest.fn().mockReturnValue(true),
|
||||
@@ -159,9 +177,111 @@ describe('withContent import state', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setPerformanceHook(null);
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('emits ordered count-only publication markers for a successful import', async () => {
|
||||
const events: RendererPerformancePhaseEvent[] = [];
|
||||
setPerformanceHook((event) => events.push(event));
|
||||
const categories = {
|
||||
live: [{ category_id: '1' }],
|
||||
vod: [{ category_id: '2' }, { category_id: '3' }],
|
||||
series: [{ category_id: '4' }],
|
||||
};
|
||||
const content = {
|
||||
live: [{ xtream_id: 1 }, { xtream_id: 2 }],
|
||||
movie: [{ xtream_id: 3 }],
|
||||
series: [{ xtream_id: 4 }, { xtream_id: 5 }],
|
||||
};
|
||||
dataSource.getCategories.mockImplementation(
|
||||
(
|
||||
_playlistId: string,
|
||||
_credentials: unknown,
|
||||
type: keyof typeof categories
|
||||
) => Promise.resolve(categories[type])
|
||||
);
|
||||
dataSource.getContent.mockImplementation(
|
||||
(
|
||||
_playlistId: string,
|
||||
_credentials: unknown,
|
||||
type: keyof typeof content
|
||||
) => Promise.resolve(content[type])
|
||||
);
|
||||
|
||||
await store.initializeContent();
|
||||
|
||||
expect(
|
||||
events.map(({ boundary, metadata, outcome, phase }) => ({
|
||||
boundary,
|
||||
items: metadata?.items,
|
||||
outcome,
|
||||
phase,
|
||||
}))
|
||||
).toEqual([
|
||||
{
|
||||
boundary: 'start',
|
||||
items: 4,
|
||||
outcome: undefined,
|
||||
phase: 'store.xtream-publish-categories',
|
||||
},
|
||||
{
|
||||
boundary: 'end',
|
||||
items: 4,
|
||||
outcome: 'success',
|
||||
phase: 'store.xtream-publish-categories',
|
||||
},
|
||||
{
|
||||
boundary: 'start',
|
||||
items: 2,
|
||||
outcome: undefined,
|
||||
phase: 'store.xtream-publish-live',
|
||||
},
|
||||
{
|
||||
boundary: 'end',
|
||||
items: 2,
|
||||
outcome: 'success',
|
||||
phase: 'store.xtream-publish-live',
|
||||
},
|
||||
{
|
||||
boundary: 'start',
|
||||
items: 1,
|
||||
outcome: undefined,
|
||||
phase: 'store.xtream-publish-vod',
|
||||
},
|
||||
{
|
||||
boundary: 'end',
|
||||
items: 1,
|
||||
outcome: 'success',
|
||||
phase: 'store.xtream-publish-vod',
|
||||
},
|
||||
{
|
||||
boundary: 'start',
|
||||
items: 2,
|
||||
outcome: undefined,
|
||||
phase: 'store.xtream-publish-series',
|
||||
},
|
||||
{
|
||||
boundary: 'end',
|
||||
items: 2,
|
||||
outcome: 'success',
|
||||
phase: 'store.xtream-publish-series',
|
||||
},
|
||||
{
|
||||
boundary: 'start',
|
||||
items: 5,
|
||||
outcome: undefined,
|
||||
phase: 'store.xtream-import-terminal',
|
||||
},
|
||||
{
|
||||
boundary: 'end',
|
||||
items: 5,
|
||||
outcome: 'success',
|
||||
phase: 'store.xtream-import-terminal',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('tracks aggregated import progress while content is loading', async () => {
|
||||
const pendingCategories = {
|
||||
live: createDeferred<any[]>(),
|
||||
@@ -350,11 +470,8 @@ describe('withContent import state', () => {
|
||||
) => pendingCategories[type].promise
|
||||
);
|
||||
dataSource.getContent.mockImplementation(
|
||||
(
|
||||
_playlistId: string,
|
||||
_credentials: unknown,
|
||||
type: ContentType
|
||||
) => pending[type].promise
|
||||
(_playlistId: string, _credentials: unknown, type: ContentType) =>
|
||||
pending[type].promise
|
||||
);
|
||||
|
||||
const initialization = store.initializeContent();
|
||||
@@ -741,6 +858,8 @@ describe('withContent import state', () => {
|
||||
});
|
||||
|
||||
it('ignores concurrent initializeContent calls while an import is already running', async () => {
|
||||
const events: RendererPerformancePhaseEvent[] = [];
|
||||
setPerformanceHook((event) => events.push(event));
|
||||
const pendingCategories = {
|
||||
live: createDeferred<any[]>(),
|
||||
vod: createDeferred<any[]>(),
|
||||
@@ -772,6 +891,11 @@ describe('withContent import state', () => {
|
||||
|
||||
expect(dataSource.getCategories).toHaveBeenCalledTimes(3);
|
||||
expect(dataSource.getContent).toHaveBeenCalledTimes(3);
|
||||
expect(
|
||||
events.filter(
|
||||
({ phase }) => phase === 'store.xtream-import-terminal'
|
||||
)
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('blocks auto restart after cancelling during category loading until retry is explicit', async () => {
|
||||
@@ -936,6 +1060,8 @@ describe('withContent import state', () => {
|
||||
});
|
||||
|
||||
it('cancels active imports and clears stale progress after worker aborts', async () => {
|
||||
const events: RendererPerformancePhaseEvent[] = [];
|
||||
setPerformanceHook((event) => events.push(event));
|
||||
const pendingCategories = {
|
||||
live: createDeferred<any[]>(),
|
||||
vod: createDeferred<any[]>(),
|
||||
@@ -1042,5 +1168,17 @@ describe('withContent import state', () => {
|
||||
expect(store.activeImportTotalCount()).toBe(0);
|
||||
expect(store.importCount()).toBe(0);
|
||||
expect(store.itemsToImport()).toBe(0);
|
||||
expect(
|
||||
events
|
||||
.filter(({ phase }) => phase === 'store.xtream-import-terminal')
|
||||
.map(({ boundary, metadata, outcome }) => ({
|
||||
boundary,
|
||||
items: metadata?.items,
|
||||
outcome,
|
||||
}))
|
||||
).toEqual([
|
||||
{ boundary: 'start', items: 0, outcome: undefined },
|
||||
{ boundary: 'end', items: 0, outcome: 'success' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,10 @@ import {
|
||||
XtreamSerieItem,
|
||||
XtreamVodStream,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
measureRendererPerformancePhase,
|
||||
RENDERER_PERFORMANCE_PHASE,
|
||||
} from '@iptvnator/shared/logging';
|
||||
import { createLogger } from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
DatabaseService,
|
||||
@@ -861,18 +865,28 @@ export function withContent() {
|
||||
logger.error('Error initializing content', error);
|
||||
}
|
||||
} finally {
|
||||
patchState(store, {
|
||||
isImporting: false,
|
||||
isCancellingImport: false,
|
||||
importCount: 0,
|
||||
importPhase: null,
|
||||
itemsToImport: 0,
|
||||
activeImportContentType: null,
|
||||
activeImportCurrentCount: 0,
|
||||
activeImportTotalCount: 0,
|
||||
activeImportSessionId: null,
|
||||
activeImportOperationIds: [],
|
||||
});
|
||||
measureRendererPerformancePhase(
|
||||
RENDERER_PERFORMANCE_PHASE.XTREAM_IMPORT_TERMINAL,
|
||||
() =>
|
||||
patchState(store, {
|
||||
isImporting: false,
|
||||
isCancellingImport: false,
|
||||
importCount: 0,
|
||||
importPhase: null,
|
||||
itemsToImport: 0,
|
||||
activeImportContentType: null,
|
||||
activeImportCurrentCount: 0,
|
||||
activeImportTotalCount: 0,
|
||||
activeImportSessionId: null,
|
||||
activeImportOperationIds: [],
|
||||
}),
|
||||
() => ({
|
||||
items:
|
||||
store.liveStreams().length +
|
||||
store.vodStreams().length +
|
||||
store.serialStreams().length,
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -962,12 +976,21 @@ export function withContent() {
|
||||
),
|
||||
]);
|
||||
|
||||
patchState(store, {
|
||||
liveCategories: live,
|
||||
vodCategories: vod,
|
||||
serialCategories: series,
|
||||
isLoadingCategories: false,
|
||||
});
|
||||
// This span ends after synchronous store publication;
|
||||
// the renderer probe owns paint observation.
|
||||
measureRendererPerformancePhase(
|
||||
RENDERER_PERFORMANCE_PHASE.XTREAM_PUBLISH_CATEGORIES,
|
||||
() =>
|
||||
patchState(store, {
|
||||
liveCategories: live,
|
||||
vodCategories: vod,
|
||||
serialCategories: series,
|
||||
isLoadingCategories: false,
|
||||
}),
|
||||
() => ({
|
||||
items: live.length + vod.length + series.length,
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
if (!isDbAbortError(error)) {
|
||||
logger.error('Error fetching categories', error);
|
||||
@@ -1044,9 +1067,14 @@ export function withContent() {
|
||||
'completed'
|
||||
);
|
||||
options?.completedTypes?.add('live');
|
||||
patchState(store, {
|
||||
liveStreams: live,
|
||||
});
|
||||
measureRendererPerformancePhase(
|
||||
RENDERER_PERFORMANCE_PHASE.XTREAM_PUBLISH_LIVE,
|
||||
() =>
|
||||
patchState(store, {
|
||||
liveStreams: live,
|
||||
}),
|
||||
() => ({ items: live.length })
|
||||
);
|
||||
updateContentTypeLoadState('live', 'ready');
|
||||
|
||||
throwIfImportCancelled(options?.importSessionId);
|
||||
@@ -1080,9 +1108,14 @@ export function withContent() {
|
||||
'completed'
|
||||
);
|
||||
options?.completedTypes?.add('vod');
|
||||
patchState(store, {
|
||||
vodStreams: vod,
|
||||
});
|
||||
measureRendererPerformancePhase(
|
||||
RENDERER_PERFORMANCE_PHASE.XTREAM_PUBLISH_VOD,
|
||||
() =>
|
||||
patchState(store, {
|
||||
vodStreams: vod,
|
||||
}),
|
||||
() => ({ items: vod.length })
|
||||
);
|
||||
updateContentTypeLoadState('vod', 'ready');
|
||||
|
||||
throwIfImportCancelled(options?.importSessionId);
|
||||
@@ -1116,10 +1149,15 @@ export function withContent() {
|
||||
'completed'
|
||||
);
|
||||
options?.completedTypes?.add('series');
|
||||
patchState(store, {
|
||||
serialStreams: series,
|
||||
isLoadingContent: false,
|
||||
});
|
||||
measureRendererPerformancePhase(
|
||||
RENDERER_PERFORMANCE_PHASE.XTREAM_PUBLISH_SERIES,
|
||||
() =>
|
||||
patchState(store, {
|
||||
serialStreams: series,
|
||||
isLoadingContent: false,
|
||||
}),
|
||||
() => ({ items: series.length })
|
||||
);
|
||||
updateContentTypeLoadState('series', 'ready');
|
||||
} catch (error) {
|
||||
if (!isDbAbortError(error)) {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { signalStore, withState } from '@ngrx/signals';
|
||||
import {
|
||||
RENDERER_PERFORMANCE_PHASE_HOOK_KEY,
|
||||
type RendererPerformancePhaseEvent,
|
||||
} from '@iptvnator/shared/logging';
|
||||
import { XTREAM_DATA_SOURCE } from '../../data-sources/xtream-data-source.interface';
|
||||
import { withSearch } from './with-search.feature';
|
||||
|
||||
jest.mock('@iptvnator/portal/shared/util', () => ({
|
||||
createLogger: () => ({ error: jest.fn() }),
|
||||
}));
|
||||
|
||||
const performanceHookSymbol = Symbol.for(RENDERER_PERFORMANCE_PHASE_HOOK_KEY);
|
||||
const TestSearchStore = signalStore(
|
||||
withState({ playlistId: 'playlist-1' }),
|
||||
withSearch()
|
||||
);
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, reject, resolve };
|
||||
}
|
||||
|
||||
function setPerformanceHook(
|
||||
hook: ((event: RendererPerformancePhaseEvent) => void) | null
|
||||
): void {
|
||||
const target = globalThis as unknown as Record<symbol, unknown>;
|
||||
if (hook === null) {
|
||||
delete target[performanceHookSymbol];
|
||||
} else {
|
||||
target[performanceHookSymbol] = hook;
|
||||
}
|
||||
}
|
||||
|
||||
describe('withSearch renderer performance markers', () => {
|
||||
let searchContent: jest.Mock;
|
||||
let store: InstanceType<typeof TestSearchStore>;
|
||||
|
||||
beforeEach(() => {
|
||||
searchContent = jest.fn();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
TestSearchStore,
|
||||
{
|
||||
provide: XTREAM_DATA_SOURCE,
|
||||
useValue: { searchContent },
|
||||
},
|
||||
],
|
||||
});
|
||||
store = TestBed.inject(TestSearchStore);
|
||||
});
|
||||
|
||||
afterEach(() => setPerformanceHook(null));
|
||||
|
||||
it('marks only the authoritative non-stale result publication', async () => {
|
||||
const events: RendererPerformancePhaseEvent[] = [];
|
||||
const first = createDeferred<unknown[]>();
|
||||
const second = createDeferred<unknown[]>();
|
||||
setPerformanceHook((event) => events.push(event));
|
||||
searchContent
|
||||
.mockReturnValueOnce(first.promise)
|
||||
.mockReturnValueOnce(second.promise);
|
||||
|
||||
const staleSearch = store.searchContent('old', ['live']);
|
||||
const authoritativeSearch = store.searchContent('new', ['live']);
|
||||
second.resolve([{ xtream_id: 2 }, { xtream_id: 3 }]);
|
||||
await authoritativeSearch;
|
||||
first.resolve([{ xtream_id: 1 }]);
|
||||
await staleSearch;
|
||||
|
||||
expect(store.searchResults()).toEqual([
|
||||
{ xtream_id: 2 },
|
||||
{ xtream_id: 3 },
|
||||
]);
|
||||
expect(
|
||||
events.map(({ boundary, metadata, outcome, phase }) => ({
|
||||
boundary,
|
||||
items: metadata?.items,
|
||||
outcome,
|
||||
phase,
|
||||
}))
|
||||
).toEqual([
|
||||
{
|
||||
boundary: 'start',
|
||||
items: 2,
|
||||
outcome: undefined,
|
||||
phase: 'store.xtream-search-results',
|
||||
},
|
||||
{
|
||||
boundary: 'end',
|
||||
items: 2,
|
||||
outcome: 'success',
|
||||
phase: 'store.xtream-search-results',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not mark an error-state reset', async () => {
|
||||
const events: RendererPerformancePhaseEvent[] = [];
|
||||
setPerformanceHook((event) => events.push(event));
|
||||
searchContent.mockRejectedValue(new Error('search failed'));
|
||||
|
||||
await expect(store.searchContent('query', ['movie'])).resolves.toEqual(
|
||||
[]
|
||||
);
|
||||
|
||||
expect(store.searchResults()).toEqual([]);
|
||||
expect(events).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,10 @@ import {
|
||||
withState,
|
||||
} from '@ngrx/signals';
|
||||
import { GlobalSearchResult } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
measureRendererPerformancePhase,
|
||||
RENDERER_PERFORMANCE_PHASE,
|
||||
} from '@iptvnator/shared/logging';
|
||||
import {
|
||||
XTREAM_DATA_SOURCE,
|
||||
XtreamContentItem,
|
||||
@@ -127,10 +131,15 @@ export function withSearch() {
|
||||
return results;
|
||||
}
|
||||
|
||||
patchState(store, {
|
||||
searchResults: results,
|
||||
isSearching: false,
|
||||
});
|
||||
measureRendererPerformancePhase(
|
||||
RENDERER_PERFORMANCE_PHASE.XTREAM_SEARCH_RESULTS,
|
||||
() =>
|
||||
patchState(store, {
|
||||
searchResults: results,
|
||||
isSearching: false,
|
||||
}),
|
||||
() => ({ items: results.length })
|
||||
);
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
|
||||
@@ -52,6 +52,7 @@ export * from './lib/xtream-response.interface';
|
||||
export * from './lib/xtream-portal.utils';
|
||||
export * from './lib/xtream-restore-state.util';
|
||||
export * from './lib/xtream-recently-added.utils';
|
||||
export * from './lib/xtream-performance-marker.interface';
|
||||
export * from './lib/xtream-serie-details.interface';
|
||||
export * from './lib/xtream-serie-item.interface';
|
||||
export * from './lib/xtream-vod-details.interface';
|
||||
|
||||
@@ -35,8 +35,35 @@ export type AppPlaylistGetPerformancePhase =
|
||||
(typeof APP_PLAYLIST_GET_PERFORMANCE_PHASE)[keyof typeof APP_PLAYLIST_GET_PERFORMANCE_PHASE];
|
||||
|
||||
export type AppPlaylistPerformancePhase =
|
||||
| AppPlaylistGetPerformancePhase
|
||||
| AppPlaylistUpsertPerformancePhase;
|
||||
AppPlaylistGetPerformancePhase | AppPlaylistUpsertPerformancePhase;
|
||||
|
||||
export const XTREAM_DATABASE_PERFORMANCE_PHASE = {
|
||||
NORMALIZE_CATEGORIES: 'normalize.categories',
|
||||
NORMALIZE_CONTENT: 'normalize.content',
|
||||
NORMALIZE_SEARCH_RANK: 'normalize.search-rank',
|
||||
SQLITE_CATEGORIES_READ: 'sqlite.categories.read',
|
||||
SQLITE_CATEGORIES_WRITE_TRANSACTIONS:
|
||||
'sqlite.categories.write-transactions',
|
||||
SQLITE_CONTENT_CATEGORY_MAP_READ: 'sqlite.content.category-map-read',
|
||||
SQLITE_CONTENT_READ: 'sqlite.content.read',
|
||||
SQLITE_CONTENT_WRITE_TRANSACTIONS: 'sqlite.content.write-transactions',
|
||||
SQLITE_PLAYLIST_DELETE_COLLECT_IDS: 'sqlite.playlist-delete.collect-ids',
|
||||
SQLITE_PLAYLIST_DELETE_WRITE_TRANSACTIONS:
|
||||
'sqlite.playlist-delete.write-transactions',
|
||||
SQLITE_SEARCH_QUERY: 'sqlite.search.query',
|
||||
SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS:
|
||||
'sqlite.xtream-cache-clear.write-transactions',
|
||||
SQLITE_XTREAM_DELETE_COLLECT_USER_DATA:
|
||||
'sqlite.xtream-delete.collect-user-data',
|
||||
SQLITE_XTREAM_DELETE_WRITE_TRANSACTIONS:
|
||||
'sqlite.xtream-delete.write-transactions',
|
||||
} as const;
|
||||
|
||||
export type XtreamDatabasePerformancePhase =
|
||||
(typeof XTREAM_DATABASE_PERFORMANCE_PHASE)[keyof typeof XTREAM_DATABASE_PERFORMANCE_PHASE];
|
||||
|
||||
export type DatabaseWorkerPerformancePhase =
|
||||
AppPlaylistPerformancePhase | XtreamDatabasePerformancePhase;
|
||||
|
||||
export interface PerformancePhaseMetadata {
|
||||
readonly byteCount?: number;
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { XtreamCodeActions } from './xtream-code-actions';
|
||||
|
||||
export const XTREAM_MAIN_PERFORMANCE_PHASE_EVENT_CHANNEL =
|
||||
'IPTVNATOR_XTREAM_MAIN_PERFORMANCE_PHASE_EVENT';
|
||||
|
||||
export const XTREAM_MAIN_PERFORMANCE_PHASE = {
|
||||
CANCEL_SESSION: 'xtream.cancel-session',
|
||||
JSON_TRANSFORM: 'xtream.json.transform',
|
||||
NETWORK_TOTAL: 'xtream.network.total',
|
||||
RESPONSE_READY: 'xtream.response.ready',
|
||||
} as const;
|
||||
|
||||
export type XtreamMainPerformancePhase =
|
||||
(typeof XTREAM_MAIN_PERFORMANCE_PHASE)[keyof typeof XTREAM_MAIN_PERFORMANCE_PHASE];
|
||||
|
||||
export const XTREAM_PRELOAD_PERFORMANCE_MARKER_CHANNEL =
|
||||
'IPTVNATOR_XTREAM_PERF_CAPTURE_MARKER';
|
||||
|
||||
export const XTREAM_PRELOAD_PERFORMANCE_SCHEMA_VERSION = 1 as const;
|
||||
|
||||
export const XTREAM_PRELOAD_PERFORMANCE_METHOD = {
|
||||
DB_CANCEL_OPERATION: 'dbCancelOperation',
|
||||
DB_DELETE_PLAYLIST: 'dbDeletePlaylist',
|
||||
DB_DELETE_XTREAM_CONTENT: 'dbDeleteXtreamContent',
|
||||
DB_GET_CATEGORIES: 'dbGetCategories',
|
||||
DB_GET_CONTENT: 'dbGetContent',
|
||||
DB_GLOBAL_SEARCH: 'dbGlobalSearch',
|
||||
DB_RESTORE_XTREAM_USER_DATA: 'dbRestoreXtreamUserData',
|
||||
DB_SAVE_CATEGORIES: 'dbSaveCategories',
|
||||
DB_SAVE_CONTENT: 'dbSaveContent',
|
||||
DB_SEARCH_CONTENT: 'dbSearchContent',
|
||||
XTREAM_CANCEL_SESSION: 'xtreamCancelSession',
|
||||
XTREAM_REQUEST: 'xtreamRequest',
|
||||
} as const;
|
||||
|
||||
export type XtreamPreloadPerformanceMethod =
|
||||
(typeof XTREAM_PRELOAD_PERFORMANCE_METHOD)[keyof typeof XTREAM_PRELOAD_PERFORMANCE_METHOD];
|
||||
|
||||
export type XtreamPreloadPerformanceBoundary = 'start' | 'end';
|
||||
export type XtreamPreloadPerformanceOutcome = 'success' | 'error' | null;
|
||||
export type XtreamPreloadPerformanceCategoryType = 'live' | 'movies' | 'series';
|
||||
export type XtreamPreloadPerformanceContentType = 'live' | 'movie' | 'series';
|
||||
|
||||
export interface XtreamPreloadPerformanceMarker {
|
||||
schemaVersion: typeof XTREAM_PRELOAD_PERFORMANCE_SCHEMA_VERSION;
|
||||
sourceEpochMs: number;
|
||||
ipcCallId: number;
|
||||
method: XtreamPreloadPerformanceMethod;
|
||||
boundary: XtreamPreloadPerformanceBoundary;
|
||||
outcome: XtreamPreloadPerformanceOutcome;
|
||||
playlistId: string | null;
|
||||
operationId: string | null;
|
||||
sessionId: string | null;
|
||||
action: XtreamCodeActions | null;
|
||||
categoryType: XtreamPreloadPerformanceCategoryType | null;
|
||||
contentType: XtreamPreloadPerformanceContentType | null;
|
||||
itemCount: number | null;
|
||||
}
|
||||
@@ -1,7 +1,16 @@
|
||||
import * as sharedLogging from '../index';
|
||||
|
||||
type RendererPerformancePhase =
|
||||
'store.m3u-import-dispatch' | 'store.m3u-publish-channels';
|
||||
| 'store.m3u-import-dispatch'
|
||||
| 'store.m3u-publish-channels'
|
||||
| 'store.xtream-delete-row'
|
||||
| 'store.xtream-import-terminal'
|
||||
| 'store.xtream-publish-categories'
|
||||
| 'store.xtream-publish-live'
|
||||
| 'store.xtream-publish-series'
|
||||
| 'store.xtream-publish-vod'
|
||||
| 'store.xtream-refresh-meta'
|
||||
| 'store.xtream-search-results';
|
||||
type RendererPerformancePhaseOutcome = 'error' | 'success';
|
||||
|
||||
interface RendererPerformancePhaseEvent {
|
||||
@@ -28,11 +37,20 @@ interface RendererPerformancePhaseModule {
|
||||
readonly RENDERER_PERFORMANCE_PHASE?: Readonly<{
|
||||
M3U_IMPORT_DISPATCH: 'store.m3u-import-dispatch';
|
||||
M3U_PUBLISH_CHANNELS: 'store.m3u-publish-channels';
|
||||
XTREAM_DELETE_ROW: 'store.xtream-delete-row';
|
||||
XTREAM_IMPORT_TERMINAL: 'store.xtream-import-terminal';
|
||||
XTREAM_PUBLISH_CATEGORIES: 'store.xtream-publish-categories';
|
||||
XTREAM_PUBLISH_LIVE: 'store.xtream-publish-live';
|
||||
XTREAM_PUBLISH_SERIES: 'store.xtream-publish-series';
|
||||
XTREAM_PUBLISH_VOD: 'store.xtream-publish-vod';
|
||||
XTREAM_REFRESH_META: 'store.xtream-refresh-meta';
|
||||
XTREAM_SEARCH_RESULTS: 'store.xtream-search-results';
|
||||
}>;
|
||||
readonly RENDERER_PERFORMANCE_PHASE_HOOK_KEY?: string;
|
||||
}
|
||||
|
||||
const rendererPerformance = sharedLogging as RendererPerformancePhaseModule;
|
||||
const rendererPerformance =
|
||||
sharedLogging as unknown as RendererPerformancePhaseModule;
|
||||
const EXPECTED_HOOK_KEY = 'iptvnator.renderer.performance-phase-hook.v1';
|
||||
const hookSymbol = Symbol.for(EXPECTED_HOOK_KEY);
|
||||
|
||||
@@ -175,6 +193,14 @@ describe('renderer performance phases', () => {
|
||||
expect(rendererPerformance.RENDERER_PERFORMANCE_PHASE).toEqual({
|
||||
M3U_IMPORT_DISPATCH: 'store.m3u-import-dispatch',
|
||||
M3U_PUBLISH_CHANNELS: 'store.m3u-publish-channels',
|
||||
XTREAM_DELETE_ROW: 'store.xtream-delete-row',
|
||||
XTREAM_IMPORT_TERMINAL: 'store.xtream-import-terminal',
|
||||
XTREAM_PUBLISH_CATEGORIES: 'store.xtream-publish-categories',
|
||||
XTREAM_PUBLISH_LIVE: 'store.xtream-publish-live',
|
||||
XTREAM_PUBLISH_SERIES: 'store.xtream-publish-series',
|
||||
XTREAM_PUBLISH_VOD: 'store.xtream-publish-vod',
|
||||
XTREAM_REFRESH_META: 'store.xtream-refresh-meta',
|
||||
XTREAM_SEARCH_RESULTS: 'store.xtream-search-results',
|
||||
});
|
||||
|
||||
const events: RendererPerformancePhaseEvent[] = [];
|
||||
|
||||
@@ -4,6 +4,14 @@ export const RENDERER_PERFORMANCE_PHASE_HOOK_KEY =
|
||||
export const RENDERER_PERFORMANCE_PHASE = {
|
||||
M3U_IMPORT_DISPATCH: 'store.m3u-import-dispatch',
|
||||
M3U_PUBLISH_CHANNELS: 'store.m3u-publish-channels',
|
||||
XTREAM_DELETE_ROW: 'store.xtream-delete-row',
|
||||
XTREAM_IMPORT_TERMINAL: 'store.xtream-import-terminal',
|
||||
XTREAM_PUBLISH_CATEGORIES: 'store.xtream-publish-categories',
|
||||
XTREAM_PUBLISH_LIVE: 'store.xtream-publish-live',
|
||||
XTREAM_PUBLISH_SERIES: 'store.xtream-publish-series',
|
||||
XTREAM_PUBLISH_VOD: 'store.xtream-publish-vod',
|
||||
XTREAM_REFRESH_META: 'store.xtream-refresh-meta',
|
||||
XTREAM_SEARCH_RESULTS: 'store.xtream-search-results',
|
||||
} as const;
|
||||
|
||||
export type RendererPerformancePhase =
|
||||
|
||||
Reference in new issue
Block a user