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) => participantsListQuery(() => now).parse({ from: ctx.range.from.toISOString(), limit: String(LIMIT), ...query }); const explainList = async (query: Record, 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(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(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][] = [ ['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); }); }); });