openvidu-test-integration: reconciler cases for the startup run, the live listing, a planned restart and a Redis without its data

The cases share one deployment, the Redis watch lasts 150 s and the MongoDB outage case waits for the session instead of fixed sleeps.
pull/906/merge
juancarmore 2026-11-02 20:15:49 +01:00
parent acb1139c9e
commit e40f171278
5 changed files with 301 additions and 44 deletions

View File

@ -34,10 +34,13 @@ describe("OpenVidu analytics during a MongoDB outage", () => {
LocalDeployment.stopContainer("mongo"); LocalDeployment.stopContainer("mongo");
pids = [Livekit.joinParticipantToRoom(identity, roomName)]; pids = [Livekit.joinParticipantToRoom(identity, roomName)];
await sleep(20); await waitFor(async () => Livekit.isParticipantInRoom(identity, roomName), 60, "the session to join");
await sleep(5);
killProcess(pids[0]); killProcess(pids[0]);
pids = []; pids = [];
await sleep(20); await waitFor(async () => !Livekit.isParticipantInRoom(identity, roomName), 60, "the session to leave");
// A few batch ticks of the writer fail while MongoDB is still down, so the leave event is retried.
await sleep(5);
LocalDeployment.startContainer("mongo"); LocalDeployment.startContainer("mongo");
await waitFor( await waitFor(

View File

@ -1,13 +1,14 @@
import { killProcess, sleep, waitFor } from "./utils/helper"; import { killProcess, sleep, waitFor } from "./utils/helper";
import { Livekit } from "./utils/livekit"; import { Livekit } from "./utils/livekit";
import { LocalDeployment } from "./utils/local-deployment"; import { LocalDeployment } from "./utils/local-deployment";
import { MongoService } from "./utils/mongodb"; import { AnalyticsDocument, MongoService } from "./utils/mongodb";
import { Collection } from "./utils/types"; import { Collection } from "./utils/types";
/** /**
* The reconciler of the OpenVidu server closes the rooms and participant sessions whose end event never arrives, * The reconciler of the OpenVidu server closes the rooms and participant sessions whose end event never arrives,
* with their tracks and subscriptions: those of a server that crashed or could not flush on shutdown. Healthy * with their tracks and subscriptions: those of a server that crashed or could not flush on shutdown, and those the
* sessions stay open, also while Redis restarts. * store holds open while LiveKit no longer has them. Healthy sessions stay open, also while Redis restarts. The cases
* share one deployment; a failed case hands a fresh one to the next.
*/ */
describe("OpenVidu analytics reconciler", () => { describe("OpenVidu analytics reconciler", () => {
const mongo = MongoService.getInstance(); const mongo = MongoService.getInstance();
@ -17,29 +18,48 @@ describe("OpenVidu analytics reconciler", () => {
const ROOM_CLOSURE_TIMEOUT_SECONDS = 420; const ROOM_CLOSURE_TIMEOUT_SECONDS = 420;
/** Statistics are flushed every 30 s: the estimated end time is at most this early. */ /** Statistics are flushed every 30 s: the estimated end time is at most this early. */
const STATS_INTERVAL_MS = 30_000; const STATS_INTERVAL_MS = 30_000;
/** How long healthy sessions are watched after Redis restarts. */ /** A session without statistics for this long is a candidate (3 × the statistics interval). */
const REDIS_RESTART_WATCH_SECONDS = 240; const STALE_AFTER_MS = 3 * STATS_INTERVAL_MS;
/** The reconciler period the CI workflow configures. */
const RECONCILER_INTERVAL_MS = 10_000;
/**
* How long healthy sessions are watched after Redis restarts: longer than a session takes to become a candidate,
* plus two reconciler runs and one more statistics flush.
*/
const REDIS_RESTART_WATCH_SECONDS = 150;
/** A candidate of an alive node is closed after two consecutive runs that do not find it in LiveKit. */
const ABSENT_CLOSURE_TIMEOUT_SECONDS = 60;
let pids: number[] = []; let pids: number[] = [];
let caseStartedAt = new Date();
let passed = false;
beforeEach(async () => { beforeAll(async () => {
await LocalDeployment.start(); await LocalDeployment.start();
}, 120000); }, 120000);
afterEach(() => { beforeEach(() => {
for (const pid of pids) { caseStartedAt = new Date();
killProcess(pid, "SIGKILL"); passed = false;
});
afterEach(async () => {
dropClients();
LocalDeployment.saveServerLogs(caseStartedAt);
if (!passed) {
LocalDeployment.stop();
await LocalDeployment.start();
} }
pids = []; }, 240000);
LocalDeployment.stop();
}, 120000);
afterAll(async () => { afterAll(async () => {
// Each case saved its own part of the log.
LocalDeployment.stop(false);
await mongo.disconnect(); await mongo.disconnect();
}); }, 120000);
/** Joins publishers to a room and waits until every session is active with statistics; returns their sids. */ /** Joins publishers to a room and waits until every session is active with statistics; returns their sids. */
async function startRoomWithPublishers(roomName: string, identities: string[]): Promise<string[]> { async function startRoomWithPublishers(roomName: string, identities: string[]): Promise<string[]> {
pids = identities.map((identity) => Livekit.joinParticipantToRoom(identity, roomName)); pids.push(...identities.map((identity) => Livekit.joinParticipantToRoom(identity, roomName)));
await waitFor( await waitFor(
async () => async () =>
(await mongo.count(Collection.PARTICIPANTS, { (await mongo.count(Collection.PARTICIPANTS, {
@ -51,7 +71,11 @@ describe("OpenVidu analytics reconciler", () => {
120, 120,
`the sessions of '${roomName}' to be active with statistics` `the sessions of '${roomName}' to be active with statistics`
); );
const participants = await mongo.findEntities(Collection.PARTICIPANTS, { roomName, status: "active" }); const participants = await mongo.findEntities(Collection.PARTICIPANTS, {
roomName,
identity: { $in: identities },
status: "active"
});
return participants.map((participant) => participant._id); return participants.map((participant) => participant._id);
} }
@ -63,11 +87,18 @@ describe("OpenVidu analytics reconciler", () => {
pids = []; pids = [];
} }
/** Waits for the server to be back after a restart that began at `since`; returns when it was seen ready. */
async function serverReady(since: Date): Promise<Date> {
LocalDeployment.startContainer("openvidu");
return LocalDeployment.waitForAnalytics(since, 120);
}
/** /**
* Every session closed by the reconciler: left with the reason RECONCILED, an end time at most one statistics * Every session closed by the reconciler: left with the reason RECONCILED, an end time at most one statistics
* interval before the failure, one synthetic event each, and no track or subscription of theirs left open. * interval before the failure, one synthetic event each naming the closure reason, and no track or subscription
* of theirs left open.
*/ */
async function expectClosedByReconciler(sids: string[], failedAt: Date) { async function expectClosedByReconciler(sids: string[], failedAt: Date, reason = "node-dead") {
for (const sid of sids) { for (const sid of sids) {
const participant = await mongo.findEntity(Collection.PARTICIPANTS, { _id: sid }); const participant = await mongo.findEntity(Collection.PARTICIPANTS, { _id: sid });
expect(participant).toMatchObject({ status: "left", closedBy: "reconciler", disconnectReason: "RECONCILED" }); expect(participant).toMatchObject({ status: "left", closedBy: "reconciler", disconnectReason: "RECONCILED" });
@ -77,7 +108,8 @@ describe("OpenVidu analytics reconciler", () => {
expect(await mongo.findSyntheticEvent(sid)).toMatchObject({ expect(await mongo.findSyntheticEvent(sid)).toMatchObject({
type: "PARTICIPANT_LEFT", type: "PARTICIPANT_LEFT",
participantSid: sid, participantSid: sid,
synthetic: true synthetic: true,
payload: { reconciler: { reason } }
}); });
} }
expect(await mongo.countOpen(Collection.TRACKS, { participantSid: { $in: sids } })).toBe(0); expect(await mongo.countOpen(Collection.TRACKS, { participantSid: { $in: sids } })).toBe(0);
@ -88,6 +120,28 @@ describe("OpenVidu analytics reconciler", () => {
).toBe(0); ).toBe(0);
} }
/**
* Inserts an active session in the room of `template`, on the same node, whose statistics stopped 100 s ago and
* that LiveKit never had: what a lost leave event leaves behind on a server that is still alive.
*/
async function seedStaleParticipant(template: AnalyticsDocument, identity: string): Promise<string> {
const sid = `PA_${identity.replace(/[^A-Za-z0-9]/g, "")}${Date.now()}`;
const joinedAt = new Date(Date.now() - 120_000);
const { leftAt, durationMs, disconnectReason, closedBy, internal, ...fields } = template;
await mongo.insertEntity(Collection.PARTICIPANTS, {
...fields,
_id: sid,
identity,
identityLower: identity.toLowerCase(),
status: "active",
joinedAt,
activeAt: joinedAt,
lastEventAt: joinedAt,
lastStatsAt: new Date(Date.now() - 100_000)
});
return sid;
}
it("closes the sessions and the room of a crashed server", async () => { it("closes the sessions and the room of a crashed server", async () => {
const roomName = "ReconcilerCrash"; const roomName = "ReconcilerCrash";
const sids = await startRoomWithPublishers(roomName, ["crash-alice", "crash-bob"]); const sids = await startRoomWithPublishers(roomName, ["crash-alice", "crash-bob"]);
@ -96,7 +150,7 @@ describe("OpenVidu analytics reconciler", () => {
LocalDeployment.killContainer("openvidu"); LocalDeployment.killContainer("openvidu");
dropClients(); dropClients();
await sleep(5); await sleep(5);
LocalDeployment.startContainer("openvidu"); await serverReady(crashedAt);
await waitFor( await waitFor(
async () => (await mongo.countOpen(Collection.PARTICIPANTS, { _id: { $in: sids } })) === 0, async () => (await mongo.countOpen(Collection.PARTICIPANTS, { _id: { $in: sids } })) === 0,
@ -109,11 +163,14 @@ describe("OpenVidu analytics reconciler", () => {
ROOM_CLOSURE_TIMEOUT_SECONDS, ROOM_CLOSURE_TIMEOUT_SECONDS,
"the room of the crashed server to be closed" "the room of the crashed server to be closed"
); );
expect(await mongo.findEntity(Collection.ROOMS, { name: roomName })).toMatchObject({ const room = await mongo.findEntity(Collection.ROOMS, { name: roomName });
status: "closed", expect(room).toMatchObject({ status: "closed", closedBy: "reconciler", endReason: "RECONCILED" });
closedBy: "reconciler", // The room never ends before its sessions: it ends with the last one.
endReason: "RECONCILED" const leftAts = (await mongo.findEntities(Collection.PARTICIPANTS, { _id: { $in: sids } })).map((participant) =>
}); (participant.leftAt as Date).getTime()
);
expect((room!.endedAt as Date).getTime()).toBe(Math.max(...leftAts));
passed = true;
}, 900000); }, 900000);
it("closes the sessions that a server stopped before its flush could not write", async () => { it("closes the sessions that a server stopped before its flush could not write", async () => {
@ -123,7 +180,7 @@ describe("OpenVidu analytics reconciler", () => {
const stoppedAt = new Date(); const stoppedAt = new Date();
LocalDeployment.stopContainerWithTimeout("openvidu", 1); LocalDeployment.stopContainerWithTimeout("openvidu", 1);
dropClients(); dropClients();
LocalDeployment.startContainer("openvidu"); await serverReady(stoppedAt);
await waitFor( await waitFor(
async () => (await mongo.countOpen(Collection.PARTICIPANTS, { _id: { $in: sids } })) === 0, async () => (await mongo.countOpen(Collection.PARTICIPANTS, { _id: { $in: sids } })) === 0,
@ -136,17 +193,147 @@ describe("OpenVidu analytics reconciler", () => {
).map((participant) => participant._id); ).map((participant) => participant._id);
await expectClosedByReconciler(reconciled, stoppedAt); await expectClosedByReconciler(reconciled, stoppedAt);
expect(await mongo.countOpen(Collection.TRACKS, { participantSid: { $in: sids } })).toBe(0); expect(await mongo.countOpen(Collection.TRACKS, { participantSid: { $in: sids } })).toBe(0);
passed = true;
}, 900000); }, 900000);
it("keeps healthy sessions open while Redis restarts, and records their real leave events", async () => { it("closes the sessions of a server that was down for longer than the stale threshold as soon as it starts", async () => {
const sids = await startRoomWithPublishers("ReconcilerStartup", ["startup-alice", "startup-bob"]);
// A stop without any grace is a crash that keeps the server down: its sessions are stale when it starts.
const crashedAt = new Date();
LocalDeployment.stopContainerWithTimeout("openvidu", 0);
dropClients();
await sleep((STALE_AFTER_MS + STATS_INTERVAL_MS) / 1000);
const readyAt = await serverReady(crashedAt);
// The startup run closes them; the first periodic run would come one reconciler period later.
let closedAt: Date | undefined;
const deadline = readyAt.getTime() + CLOSURE_TIMEOUT_SECONDS * 1000;
while (!closedAt && Date.now() < deadline) {
if ((await mongo.countOpen(Collection.PARTICIPANTS, { _id: { $in: sids } })) === 0) {
closedAt = new Date();
} else {
await sleep(0.5);
}
}
expect(closedAt).toBeDefined();
expect(closedAt!.getTime() - readyAt.getTime()).toBeLessThan(RECONCILER_INTERVAL_MS - 2_000);
await expectClosedByReconciler(sids, crashedAt);
passed = true;
}, 900000);
it("closes the sessions and the rooms that an alive server's LiveKit no longer has", async () => {
const roomName = "ReconcilerAbsent";
const [realSid] = await startRoomWithPublishers(roomName, ["absent-alice"]);
const participant = (await mongo.findEntity(Collection.PARTICIPANTS, { _id: realSid }))!;
const room = (await mongo.findEntity(Collection.ROOMS, { name: roomName, status: "active" }))!;
// A session of the live room that LiveKit never listed, and an empty room past its empty timeout that LiveKit
// never had, both of the alive server.
const ghostSid = await seedStaleParticipant(participant, "absent-ghost");
const emptyRoomSid = `RM_ReconcilerAbsentEmpty${Date.now()}`;
const lastEventAt = new Date(Date.now() - 8 * 60_000);
const { endedAt, durationMs, endReason, closedBy, internal, ...roomFields } = room;
await mongo.insertEntity(Collection.ROOMS, {
...roomFields,
_id: emptyRoomSid,
name: "ReconcilerAbsentEmpty",
nameLower: "reconcilerabsentempty",
createdAt: new Date(lastEventAt.getTime() - 60_000),
lastEventAt,
lastStatsAt: lastEventAt,
participants: { sessions: 0, unique: 0, active: 0, peakActive: 0, aborted: 0, byKind: {} }
});
await waitFor(
async () =>
(await mongo.countOpen(Collection.PARTICIPANTS, { _id: ghostSid })) === 0 &&
(await mongo.countOpen(Collection.ROOMS, { _id: emptyRoomSid })) === 0,
ABSENT_CLOSURE_TIMEOUT_SECONDS,
"the entities LiveKit does not have to be closed"
);
expect(await mongo.findEntity(Collection.PARTICIPANTS, { _id: ghostSid })).toMatchObject({
status: "left",
closedBy: "reconciler",
disconnectReason: "RECONCILED"
});
expect(await mongo.findSyntheticEvent(ghostSid)).toMatchObject({
type: "PARTICIPANT_LEFT",
payload: { reconciler: { reason: "absent-live" } }
});
expect(await mongo.findEntity(Collection.ROOMS, { _id: emptyRoomSid })).toMatchObject({
status: "closed",
closedBy: "reconciler",
endReason: "RECONCILED",
endedAt: lastEventAt
});
expect(await mongo.findSyntheticEvent(emptyRoomSid)).toMatchObject({
type: "ROOM_ENDED",
payload: { reconciler: { reason: "absent-live" } }
});
// The sessions LiveKit lists stay open.
expect(await mongo.countOpen(Collection.PARTICIPANTS, { _id: realSid })).toBe(1);
expect(await mongo.countOpen(Collection.ROOMS, { _id: room._id })).toBe(1);
passed = true;
}, 900000);
it("closes the rooms and sessions of a planned restart with their real end events", async () => {
const roomName = "ReconcilerPlannedRestart";
const sids = await startRoomWithPublishers(roomName, ["planned-alice", "planned-bob"]);
const startedAt = LocalDeployment.containerStartedAt("openvidu");
// The first SIGTERM makes the server drain; the second one closes its rooms and it exits after its flush.
const stoppingAt = new Date();
LocalDeployment.signalContainer("openvidu", "SIGTERM");
await sleep(3);
LocalDeployment.signalContainer("openvidu", "SIGTERM");
dropClients();
await waitFor(
async () =>
!LocalDeployment.isContainerRunning("openvidu") ||
LocalDeployment.containerStartedAt("openvidu") !== startedAt,
60,
"the server to exit"
);
await serverReady(stoppingAt);
const room = await mongo.findEntity(Collection.ROOMS, { name: roomName });
expect(room).toMatchObject({ status: "closed", closedBy: "event", endReason: "SERVER_SHUTDOWN" });
for (const sid of sids) {
const participant = await mongo.findEntity(Collection.PARTICIPANTS, { _id: sid });
expect(participant).toMatchObject({ status: "left", closedBy: "event" });
// The final statistics were flushed on the way out.
expect((participant!.lastStatsAt as Date).getTime()).toBeGreaterThanOrEqual(stoppingAt.getTime());
expect(await mongo.findSyntheticEvent(sid)).toBeNull();
}
const types = (await mongo.findEvents({ roomName })).map((event) => event.type as string);
expect(types).toContain("ROOM_ENDED");
expect(types.filter((type) => type === "PARTICIPANT_LEFT")).toHaveLength(sids.length);
passed = true;
}, 900000);
it("keeps healthy sessions open while Redis restarts and loses its state, trusts LiveKit again once it lists rooms, and records the real leave events", async () => {
const roomName = "ReconcilerRedis"; const roomName = "ReconcilerRedis";
const sids = await startRoomWithPublishers(roomName, ["redis-alice", "redis-bob"]); const sids = await startRoomWithPublishers(roomName, ["redis-alice", "redis-bob"]);
const template = (await mongo.findEntity(Collection.PARTICIPANTS, { _id: sids[0] }))!;
const runBefore = await mongo.schemaMeta("reconciler"); const runBefore = await mongo.schemaMeta("reconciler");
// The deployment's Redis persists its data, so it comes back with LiveKit's listing; emptying it is a Redis
// restarted without its data.
const restartedAt = new Date(); const restartedAt = new Date();
LocalDeployment.restartContainer("redis"); LocalDeployment.restartContainer("redis");
LocalDeployment.flushRedis();
// A stale session of the live room: while LiveKit lists no room and the room still receives statistics, the
// listing is not trusted and the session is kept.
const ghostSid = await seedStaleParticipant(template, "redis-ghost");
await waitFor(
async () => LocalDeployment.serverLogsSince(restartedAt).includes("live-state-untrusted"),
60,
"a reconciler run that does not trust the empty live listing"
);
expect(await mongo.countOpen(Collection.PARTICIPANTS, { _id: ghostSid })).toBe(1);
const deadline = Date.now() + REDIS_RESTART_WATCH_SECONDS * 1000; const deadline = restartedAt.getTime() + REDIS_RESTART_WATCH_SECONDS * 1000;
while (Date.now() < deadline) { while (Date.now() < deadline) {
expect(await mongo.countOpen(Collection.PARTICIPANTS, { _id: { $in: sids } })).toBe(sids.length); expect(await mongo.countOpen(Collection.PARTICIPANTS, { _id: { $in: sids } })).toBe(sids.length);
await sleep(10); await sleep(10);
@ -157,6 +344,19 @@ describe("OpenVidu analytics reconciler", () => {
Math.max(restartedAt.getTime(), (runBefore?.lastRunAt as Date | undefined)?.getTime() ?? 0) Math.max(restartedAt.getTime(), (runBefore?.lastRunAt as Date | undefined)?.getTime() ?? 0)
); );
// A new room puts LiveKit's listing back in Redis: the listing is trusted again and the stale session, which
// it does not list, is closed.
pids.push(Livekit.joinParticipantToRoom("redis-carol", "ReconcilerRedisAfter"));
await waitFor(
async () => (await mongo.countOpen(Collection.PARTICIPANTS, { _id: ghostSid })) === 0,
ABSENT_CLOSURE_TIMEOUT_SECONDS,
"the stale session to be closed once the listing is trusted"
);
expect(await mongo.findSyntheticEvent(ghostSid)).toMatchObject({
payload: { reconciler: { reason: "absent-live" } }
});
expect(await mongo.countOpen(Collection.PARTICIPANTS, { _id: { $in: sids } })).toBe(sids.length);
for (const pid of pids) { for (const pid of pids) {
killProcess(pid); killProcess(pid);
} }
@ -166,8 +366,7 @@ describe("OpenVidu analytics reconciler", () => {
120, 120,
"the sessions to be closed by their leave events" "the sessions to be closed by their leave events"
); );
expect( expect(await mongo.count(Collection.PARTICIPANTS, { _id: { $in: sids }, closedBy: "event" })).toBe(sids.length);
await mongo.count(Collection.PARTICIPANTS, { _id: { $in: sids }, closedBy: "event" }) passed = true;
).toBe(sids.length);
}, 900000); }, 900000);
}); });

View File

@ -1,3 +1,4 @@
import { execSync } from "child_process";
import { execCommandInBackground } from "./helper"; import { execCommandInBackground } from "./helper";
const LIVEKIT_URL = "ws://localhost:7880"; const LIVEKIT_URL = "ws://localhost:7880";
@ -30,4 +31,16 @@ export class Livekit {
} }
return pid; return pid;
} }
/** Whether LiveKit lists a participant with this identity in the room; false when the room does not exist. */
static isParticipantInRoom(participantIdentity: string, roomName: string): boolean {
const args = ["--url", LIVEKIT_URL, "--api-key", LIVEKIT_API_KEY, "--api-secret", LIVEKIT_API_SECRET];
try {
return execSync(`${LK_CLI} room participants list ${args.join(" ")} ${roomName}`, { stdio: "pipe" })
.toString()
.includes(participantIdentity);
} catch {
return false;
}
}
} }

View File

@ -20,17 +20,29 @@ export class LocalDeployment {
console.log("Local deployment started"); console.log("Local deployment started");
} }
static stop() { /** Removes the deployment, saving the server log first unless the caller already saved what it needs. */
static stop(saveLogs = true) {
console.log("Stopping local deployment..."); console.log("Stopping local deployment...");
LocalDeployment.saveServerLogs(); if (saveLogs) {
LocalDeployment.saveServerLogs();
}
execCommand(`docker compose -f ${LOCAL_DEPLOYMENT_PATH} down -v`); execCommand(`docker compose -f ${LOCAL_DEPLOYMENT_PATH} down -v`);
} }
/** Saves the log of the OpenVidu server in logs/, named after the current test, before the deployment is removed. */ /**
private static saveServerLogs() { * Saves the log of the OpenVidu server in logs/, named after the current test, before the deployment is removed;
* with `since`, only what the server logged from then on.
*/
static saveServerLogs(since?: Date) {
const name = (expect.getState().currentTestName ?? "deployment").replace(/[^\w-]+/g, "-"); const name = (expect.getState().currentTestName ?? "deployment").replace(/[^\w-]+/g, "-");
const sinceArg = since ? `--since ${since.toISOString()}` : "";
mkdirSync(LOGS_PATH, { recursive: true }); mkdirSync(LOGS_PATH, { recursive: true });
execCommand(`docker logs openvidu > ${join(LOGS_PATH, `${name}.log`)} 2>&1 || true`); execCommand(`docker logs ${sinceArg} openvidu > ${join(LOGS_PATH, `${name}.log`)} 2>&1 || true`);
}
/** What the OpenVidu server logged since the given time. */
static serverLogsSince(since: Date): string {
return execCommand(`docker logs --since ${since.toISOString()} openvidu 2>&1 || true`);
} }
static startContainer(containerName: string) { static startContainer(containerName: string) {
@ -55,22 +67,47 @@ export class LocalDeployment {
execCommand(`docker kill ${containerName}`); execCommand(`docker kill ${containerName}`);
} }
/** Sends a signal to the main process of a container, as an orchestrator does on a planned stop. */
static signalContainer(containerName: string, signal: NodeJS.Signals) {
console.log(`Sending ${signal} to container ${containerName}...`);
execCommand(`docker kill --signal=${signal} ${containerName}`);
}
/** When the current run of a container started, to tell a restarted container from the previous run. */
static containerStartedAt(containerName: string): string {
return execCommand(`docker inspect -f {{.State.StartedAt}} ${containerName}`);
}
static isContainerRunning(containerName: string): boolean {
return execCommand(`docker inspect -f {{.State.Running}} ${containerName}`) === "true";
}
/**
* Empties Redis, as a Redis restarted without its persisted data: LiveKit's room listing is gone while the rooms
* keep running on the server.
*/
static flushRedis() {
console.log("Flushing Redis...");
execCommand("docker exec redis redis-cli --no-auth-warning -a redispassword FLUSHALL");
}
static restartContainer(containerName: string) { static restartContainer(containerName: string) {
console.log(`Restarting container ${containerName}...`); console.log(`Restarting container ${containerName}...`);
execCommand(`docker restart ${containerName}`); execCommand(`docker restart ${containerName}`);
} }
/** Waits until the OpenVidu server logs that its analytics database is initialized after the given time. */ /**
static async waitForAnalytics(since: Date, timeoutSeconds = 60) { * Waits until the OpenVidu server logs that its analytics database is initialized after the given time, and
* returns when that was seen, polling every half second.
*/
static async waitForAnalytics(since: Date, timeoutSeconds = 60): Promise<Date> {
const deadline = Date.now() + timeoutSeconds * 1000; const deadline = Date.now() + timeoutSeconds * 1000;
const sinceArg = since.toISOString();
while (Date.now() < deadline) { while (Date.now() < deadline) {
const logs = execCommand(`docker logs --since ${sinceArg} openvidu 2>&1 || true`); if (LocalDeployment.serverLogsSince(since).includes("analytics database initialized")) {
if (logs.includes("analytics database initialized")) { return new Date();
return;
} }
await sleep(1); await sleep(0.5);
} }
throw new Error("The OpenVidu server did not initialize its analytics database"); throw new Error("The OpenVidu server did not initialize its analytics database");

View File

@ -67,6 +67,11 @@ export class MongoService {
return this.count(collection, { ...filter, status: { $in: OPEN_STATUSES[collection] ?? [] } }); return this.count(collection, { ...filter, status: { $in: OPEN_STATUSES[collection] ?? [] } });
} }
/** Inserts a document as the writer would have left it, for the cases that need a state no failure can produce. */
public async insertEntity(collection: Collection, doc: AnalyticsDocument): Promise<void> {
await this.db.collection<AnalyticsDocument>(collection).insertOne(doc);
}
public async findEvents(filter: Query): Promise<Document[]> { public async findEvents(filter: Query): Promise<Document[]> {
return this.db.collection<AnalyticsDocument>(Collection.EVENTS).find(filter).sort({ ts: 1 }).toArray(); return this.db.collection<AnalyticsDocument>(Collection.EVENTS).find(filter).sort({ ts: 1 }).toArray();
} }