mirror of https://github.com/OpenVidu/openvidu.git
286 lines
10 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|