openvidu/openvidu-dashboard/dashboard-ce/backend/test/integration/participants-query-plan.tes...

286 lines
10 KiB
TypeScript

import { writeFileSync } from 'node:fs';
import { generate } from '@openvidu-dashboard/data-generator/generator';
import { queryContext, type QueryContext } from '@openvidu-dashboard/data-generator/context';
import { type Db, type Document, MongoClient } from 'mongodb';
import { afterAll, beforeAll, describe, expect, inject, it } from 'vitest';
import { Collection, ParticipantKind, type ParticipantDocument, ParticipantStatus } from '@openvidu-dashboard/typings';
import { rankedPipeline } from '../../src/api/ranked.js';
import { prefixRegex } from '../../src/api/search.js';
import { PARTICIPANT_LIST_PROJECTION, participantsListPlan } from '../../src/participants/participants-query.js';
import { participantsListQuery } from '../../src/participants/participants-schemas.js';
import { ParticipantsService } from '../../src/participants/participants-service.js';
import { WriterFreshness } from '../../src/status/freshness.js';
import { expectWithinBudgets } from '../support/latency-budget.js';
import { expectIdLookup, expectIndexBacked, expectIndexes, p95 } from '../support/query-plan.js';
/**
* Every participants query of the API explained on a database generated by the data generator, plus the
* p95 of the performance budget. PARTICIPANTS_PLAN_SCENARIO (S by default), PARTICIPANTS_PLAN_DAYS (2 by default) and
* PARTICIPANTS_PLAN_REPORT (a file that also receives the report) work as in the rooms suite.
*/
const mongoUri = inject('mongoUri');
const scenario = (process.env.PARTICIPANTS_PLAN_SCENARIO ?? 'S') as 'S' | 'M' | 'L';
const days = Number(process.env.PARTICIPANTS_PLAN_DAYS ?? '2');
const DATABASE = 'dashboard_test_participants_plan';
const LIMIT = 100;
const DAY = 86_400_000;
const GENERATION_TIMEOUT_MS = scenario === 'S' ? 600_000 : 3_600_000;
describe.skipIf(mongoUri === '')(`participants query plan at scenario ${scenario}`, () => {
let client: MongoClient;
let db: Db;
let ctx: QueryContext;
let service: ParticipantsService;
let sample: ParticipantDocument;
const now = new Date();
const report: string[] = [];
const listParams = (query: Record<string, string>) =>
participantsListQuery(() => now).parse({ from: ctx.range.from.toISOString(), limit: String(LIMIT), ...query });
const explainList = async (query: Record<string, string>, bound: number, pinnedKind?: ParticipantKind) => {
const params = listParams(query);
const plan = participantsListPlan(pinnedKind === undefined ? params : { ...params, kind: [pinnedKind] });
if (plan.kind === 'ranked') {
const inRange = await db.collection(Collection.PARTICIPANTS).countDocuments(plan.countFilter);
const request = {
...params,
match: plan.match,
rank: plan.rank,
projection: PARTICIPANT_LIST_PROJECTION,
cursor: undefined,
now
};
return expectIndexBacked(
db,
{
collection: Collection.PARTICIPANTS,
pipeline: rankedPipeline(request, now),
...(plan.hint === undefined ? {} : { options: { hint: plan.hint } })
},
{ maxDocsExamined: inRange }
);
}
return expectIndexBacked(
db,
db
.collection<ParticipantDocument>(Collection.PARTICIPANTS)
.find(plan.filter, {
projection: PARTICIPANT_LIST_PROJECTION,
...(plan.kind === 'page' && plan.hint !== undefined ? { hint: plan.hint } : {})
})
.sort(plan.kind === 'page' ? plan.sort : { _id: 1 })
.limit(LIMIT + 1),
{ maxDocsExamined: bound }
);
};
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
});
ctx = await queryContext(db);
service = new ParticipantsService(db, {
totalMaxRangeMs: 7 * DAY,
freshness: new WriterFreshness(db, () => now),
now: () => now
});
const found = await db
.collection<ParticipantDocument>(Collection.PARTICIPANTS)
.findOne({ _id: ctx.samples.participantSid });
if (found === null) {
throw new Error('the generated database has no sample participant');
}
sample = found;
}, GENERATION_TIMEOUT_MS);
afterAll(async () => {
if (report.length > 0) {
const text = [`Participants p95 of 20 runs, scenario ${scenario}, ${String(days)} days`, ...report].join(
'\n'
);
console.log(text);
if (process.env.PARTICIPANTS_PLAN_REPORT !== undefined) {
writeFileSync(process.env.PARTICIPANTS_PLAN_REPORT, `${text}\n`);
}
}
await db.dropDatabase();
await client.close();
});
describe('lists', () => {
const page = LIMIT + 2;
it('no filter: joinedAt_id without a sort', async () => {
const plan = await explainList({}, page);
expectIndexes(plan, ['joinedAt_id']);
expect(plan.planStages).not.toContain('SORT');
});
it('room: roomSid_joinedAt_id without a sort', async () => {
const plan = await explainList({ room: sample.roomSid, status: 'left' }, LIMIT + 2 + 1_000);
expectIndexes(plan, ['roomSid_joinedAt_id']);
expect(plan.planStages).not.toContain('SORT');
});
it('kind and status: kind_status_joinedAt_id merged without a sort, also for the agents list', async () => {
for (const plan of [
await explainList({ kind: 'AGENT,EGRESS' }, 2 * page),
await explainList({ status: 'active' }, 7 * page),
await explainList({ status: 'left' }, page, ParticipantKind.AGENT)
]) {
expectIndexes(plan, ['kind_status_joinedAt_id']);
expect(plan.planStages).not.toContain('SORT');
}
});
it('identity prefix: identityLower_joinedAt within the prefix range', async () => {
const inPrefix = await db
.collection(Collection.PARTICIPANTS)
.countDocuments({ identityLower: prefixRegex(ctx.samples.identityPrefix) });
expectIndexes(await explainList({ q: ctx.samples.identityPrefix }, inPrefix), ['identityLower_joinedAt']);
expectIndexes(await explainList({ q: sample.identityLower.slice(0, 3), room: sample.roomSid }, inPrefix), [
'roomSid_identityLower_joinedAt'
]);
});
it('SID: _id', async () => {
expectIdLookup(await explainList({ q: sample._id }, 1));
});
it.each(['leftAt', 'duration'])(
'ranked by %s: the range index of the filters, at most the sessions in range',
async (sort) => {
expectIndexes(await explainList({ sort }, page), ['joinedAt_id']);
expectIndexes(await explainList({ sort, status: 'active' }, page), ['kind_status_joinedAt_id']);
expectIndexes(await explainList({ sort, status: 'left' }, page, ParticipantKind.AGENT), [
'kind_status_joinedAt_id'
]);
expectIndexes(await explainList({ sort, room: sample.roomSid }, page), ['roomSid_joinedAt_id']);
expectIndexes(await explainList({ sort, q: ctx.samples.identityPrefix }, page), [
'identityLower_joinedAt'
]);
}
);
it('publisher: kind_status_joinedAt_id with isPublisher residual', async () => {
const active = await db
.collection(Collection.PARTICIPANTS)
.countDocuments({ status: 'active', joinedAt: { $gte: ctx.range.from } });
expectIndexes(await explainList({ status: 'active', publisher: 'true' }, active), [
'kind_status_joinedAt_id'
]);
});
it('summary: active sessions through the status indexes, in-range sessions through joinedAt_id', async () => {
const kinds = Object.values(ParticipantKind);
const participants = db.collection(Collection.PARTICIPANTS);
const checks: [Document, string[]][] = [
[
{ status: ParticipantStatus.ACTIVE, kind: { $in: kinds } },
['status_kind', 'status_lastStatsAt_open']
],
[{ joinedAt: { $gte: new Date(now.getTime() - DAY), $lt: now } }, ['joinedAt_id']]
];
for (const [match, allowed] of checks) {
const plan = await expectIndexBacked(
db,
{
collection: Collection.PARTICIPANTS,
pipeline: [{ $match: match }, { $group: { _id: '$kind', n: { $sum: 1 } } }]
},
{ maxDocsExamined: await participants.countDocuments(match) }
);
expect(plan.collscan).toBe(false);
expect(plan.indexes.length).toBeGreaterThan(0);
expect(plan.indexes.every((index) => allowed.includes(index))).toBe(true);
}
});
it.each([
['active sessions', { status: 'active' }],
['all sessions', {}]
])('walks forward and back on %s', async (_name, query) => {
const params = listParams({ ...query, limit: '10' });
const first = await service.list(params);
const second = await service.list({ ...params, cursor: first.nextCursor ?? '' });
const back = await service.list({ ...params, cursor: second.prevCursor ?? '' });
const ids = (p: { items: { sid: string }[] }) => p.items.map((item) => item.sid);
expect(ids(first)).toHaveLength(10);
expect(ids(back)).toEqual(ids(first));
expect(new Set([...ids(first), ...ids(second)]).size).toBe(ids(first).length + ids(second).length);
});
});
describe('performance budget', () => {
it('reports the p95 of every budgeted query', async () => {
const rows: [string, string, () => Promise<unknown>][] = [
['List (100 rows)', '50 ms', () => service.list(listParams({}))],
['List, status active', '50 ms', () => service.list(listParams({ status: 'active' }))],
['List, identity prefix', '50 ms', () => service.list(listParams({ q: ctx.samples.identityPrefix }))],
['Agents list', '50 ms', () => service.agents(listParams({}))],
[
'Summary, 24 h',
'50 ms',
() => service.summary({ from: new Date(now.getTime() - DAY), to: now, tz: 'UTC' })
]
];
const lastDay = { from: new Date(now.getTime() - DAY).toISOString() };
for (const sort of ['leftAt', 'duration']) {
rows.push([
`List, ranked by ${sort} (24 h)`,
'300 ms',
() => service.list(listParams({ ...lastDay, sort }))
]);
}
report.push('| Query | Budget | p95 |', '| --- | --- | --- |');
const overruns: string[] = [];
for (const [name, budget, run] of rows) {
const measured = await p95(run);
report.push(`| ${name} | ${budget} | ${measured.toFixed(1)} ms |`);
if (name.startsWith('List, ranked') && measured > Number.parseFloat(budget)) {
overruns.push(`${name}: ${measured.toFixed(1)} ms > ${budget}`);
}
}
expect(report.length).toBe(rows.length + 2);
expectWithinBudgets(overruns);
});
});
});