openvidu/openvidu-dashboard/dashboard-ce/backend/test/integration/overview-query-plan.test.ts

160 lines
6.0 KiB
TypeScript
Raw Normal View History

import { writeFileSync } from 'node:fs';
import { generate } from '@openvidu-dashboard/data-generator/generator';
import { summarizeExplain } from '@openvidu-dashboard/data-generator/plan';
import { type Db, type Document, MongoClient } from 'mongodb';
import { afterAll, beforeAll, describe, expect, inject, it } from 'vitest';
import { Collection, freshnessWindows } from '@openvidu-dashboard/typings';
import {
activeParticipantsPipeline,
activeRoomsPipeline,
IN_FLIGHT_EGRESS,
PUBLISHING_INGRESS,
OverviewRepository,
RECENT_ROOMS
} from '../../src/overview/overview-repository.js';
import { OverviewService } from '../../src/overview/overview-service.js';
import { todayWindow } from '../../src/overview/overview-window.js';
import { ROOM_LIST_PROJECTION } from '../../src/rooms/rooms-query.js';
import { expectIndexBacked, expectIndexes, p95 } from '../support/query-plan.js';
import { expectWithinBudgets } from '../support/latency-budget.js';
/**
* Every overview query explained on a database generated by the data generator, plus the p95 of the performance budget,
* which fails the suite when exceeded. OVERVIEW_PLAN_SCENARIO (S by default), OVERVIEW_PLAN_DAYS (2 by default) and
* OVERVIEW_PLAN_REPORT (a file that also receives the report) work as in the rooms suite.
*/
const mongoUri = inject('mongoUri');
const scenario = (process.env.OVERVIEW_PLAN_SCENARIO ?? 'S') as 'S' | 'M' | 'L';
const days = Number(process.env.OVERVIEW_PLAN_DAYS ?? '2');
const DATABASE = 'dashboard_test_overview_plan';
const GENERATION_TIMEOUT_MS = scenario === 'S' ? 600_000 : 3_600_000;
describe.skipIf(mongoUri === '')(`overview query plan at scenario ${scenario}`, () => {
let client: MongoClient;
let db: Db;
let service: OverviewService;
const now = new Date();
const threshold = new Date(now.getTime() - freshnessWindows().staleMs);
const report: string[] = [];
const aggregate = (collection: Collection, pipeline: Document[], hint?: string) =>
db
.collection(collection)
.aggregate(pipeline, hint === undefined ? {} : { hint })
.explain('executionStats')
.then(summarizeExplain);
beforeAll(async () => {
client = new MongoClient(mongoUri);
db = client.db(DATABASE);
await db.dropDatabase();
await generate(db, {
scenario,
days,
statsMode: 'participant',
seed: 1,
retentionDays: { events: 7, stats: 7, tracks: 7, entities: 90 },
timeseries: true
});
service = new OverviewService(db, { now: () => now, extension: () => ({}) });
}, GENERATION_TIMEOUT_MS);
afterAll(async () => {
if (report.length > 0) {
const text = [`Overview p95 of 20 runs, scenario ${scenario}, ${String(days)} days`, ...report].join('\n');
console.log(text);
if (process.env.OVERVIEW_PLAN_REPORT !== undefined) {
writeFileSync(process.env.OVERVIEW_PLAN_REPORT, `${text}\n`);
}
}
await db.dropDatabase();
await client.close();
});
it('active groups examine exactly the active rooms and participants', async () => {
const activeRooms = await db.collection(Collection.ROOMS).countDocuments({ status: 'active' });
const activeParticipants = await db.collection(Collection.PARTICIPANTS).countDocuments({ status: 'active' });
const rooms = await aggregate(Collection.ROOMS, activeRoomsPipeline(threshold), 'status_createdAt_id');
const participants = await aggregate(Collection.PARTICIPANTS, activeParticipantsPipeline(threshold));
expect(activeRooms).toBeGreaterThan(0);
expectIndexes(rooms, ['status_createdAt_id']);
expect(rooms.totalDocsExamined).toBe(activeRooms);
expect(participants.collscan).toBe(false);
expect(participants.indexes.some((index) => ['status_kind', 'kind_status_joinedAt_id'].includes(index))).toBe(
true
);
expect(participants.totalDocsExamined).toBe(activeParticipants);
});
it('in-flight counts read only index keys', async () => {
const count = (filter: Document) => [{ $match: filter }, { $group: { _id: 1, n: { $sum: 1 } } }];
const egresses = await aggregate(Collection.EGRESSES, count(IN_FLIGHT_EGRESS));
const ingress = await aggregate(Collection.INGRESS_SESSIONS, count(PUBLISHING_INGRESS));
expectIndexes(egresses, ['status_startedAt']);
expect(egresses.totalDocsExamined).toBe(0);
expectIndexes(ingress, ['status']);
expect(ingress.totalDocsExamined).toBe(0);
});
it('recent rooms merge two sorted index branches and today reads at most 25 rollups', async () => {
const recent = await expectIndexBacked(
db,
db
.collection(Collection.ROOMS)
.find({ status: { $in: ['active', 'closed'] } }, { projection: ROOM_LIST_PROJECTION })
.sort({ createdAt: -1, _id: -1 })
.limit(RECENT_ROOMS),
{ maxDocsExamined: RECENT_ROOMS }
);
const window = todayWindow(now, 'Pacific/Kiritimati');
const today = await expectIndexBacked(
db,
db
.collection(Collection.ANALYTICS_HOURLY)
.find({ scope: 'global', bucket: { $gte: window.from, $lt: now } })
.sort({ bucket: 1 }),
{ maxDocsExamined: 25 }
);
expectIndexes(recent, ['status_createdAt_id']);
expect(recent.planStages).not.toContain('SORT');
expect(recent.totalKeysExamined).toBeLessThanOrEqual(2 * RECENT_ROOMS);
expectIndexes(today, ['scope_bucket']);
expect(today.nReturned).toBeLessThanOrEqual(25);
});
it('reports the p95 of every budgeted query and fails above its budget', async () => {
const repository = new OverviewRepository(db);
const rows: [string, number, () => Promise<unknown>][] = [
['Full overview (PRO, with nodes)', 100, () => service.overview('Europe/Madrid')],
['Active rooms groups', 30, () => repository.roomGroups(threshold)],
['Active participant groups', 30, () => repository.participantGroups(threshold)]
];
report.push('| Query | Budget | p95 |', '| --- | --- | --- |');
const over: string[] = [];
for (const [name, budget, run] of rows) {
const measured = await p95(run);
report.push(`| ${name} | ${String(budget)} ms | ${measured.toFixed(1)} ms |`);
if (measured > budget) {
over.push(`${name}: ${measured.toFixed(1)} ms > ${String(budget)} ms`);
}
}
expectWithinBudgets(over);
});
});