2026-10-15 20:47:40 +02:00
import { killProcess , sleep , waitFor } from "./utils/helper" ;
import { Livekit } from "./utils/livekit" ;
import { LocalDeployment } from "./utils/local-deployment" ;
2026-11-02 20:15:49 +01:00
import { AnalyticsDocument , MongoService } from "./utils/mongodb" ;
2026-10-15 20:47:40 +02:00
import { Collection } from "./utils/types" ;
/ * *
* The reconciler of the OpenVidu server closes the rooms and participant sessions whose end event never arrives ,
2026-11-02 20:15:49 +01:00
* with their tracks and subscriptions : those of a server that crashed or could not flush on shutdown , and those the
* 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 .
2026-10-15 20:47:40 +02:00
* /
describe ( "OpenVidu analytics reconciler" , ( ) = > {
const mongo = MongoService . getInstance ( ) ;
/** A crashed session is closed within this time of the restart (reconciler period of 10 s in CI). */
const CLOSURE_TIMEOUT_SECONDS = 180 ;
/** A room is closed once its empty timeout (300 s) and two reconciler periods passed since its last event. */
const ROOM_CLOSURE_TIMEOUT_SECONDS = 420 ;
/** Statistics are flushed every 30 s: the estimated end time is at most this early. */
const STATS_INTERVAL_MS = 30 _000 ;
2026-11-02 20:15:49 +01:00
/** A session without statistics for this long is a candidate (3 × the statistics interval). */
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 ;
2026-10-15 20:47:40 +02:00
let pids : number [ ] = [ ] ;
2026-11-02 20:15:49 +01:00
let caseStartedAt = new Date ( ) ;
let passed = false ;
2026-10-15 20:47:40 +02:00
2026-11-02 20:15:49 +01:00
beforeAll ( async ( ) = > {
2026-10-15 20:47:40 +02:00
await LocalDeployment . start ( ) ;
} , 120000 ) ;
2026-11-02 20:15:49 +01:00
beforeEach ( ( ) = > {
caseStartedAt = new Date ( ) ;
passed = false ;
} ) ;
afterEach ( async ( ) = > {
dropClients ( ) ;
LocalDeployment . saveServerLogs ( caseStartedAt ) ;
if ( ! passed ) {
LocalDeployment . stop ( ) ;
await LocalDeployment . start ( ) ;
2026-10-15 20:47:40 +02:00
}
2026-11-02 20:15:49 +01:00
} , 240000 ) ;
2026-10-15 20:47:40 +02:00
afterAll ( async ( ) = > {
2026-11-02 20:15:49 +01:00
// Each case saved its own part of the log.
LocalDeployment . stop ( false ) ;
2026-10-15 20:47:40 +02:00
await mongo . disconnect ( ) ;
2026-11-02 20:15:49 +01:00
} , 120000 ) ;
2026-10-15 20:47:40 +02:00
/** 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 [ ] > {
2026-11-02 20:15:49 +01:00
pids . push ( . . . identities . map ( ( identity ) = > Livekit . joinParticipantToRoom ( identity , roomName ) ) ) ;
2026-10-15 20:47:40 +02:00
await waitFor (
async ( ) = >
( await mongo . count ( Collection . PARTICIPANTS , {
roomName ,
identity : { $in : identities } ,
status : "active" ,
lastStatsAt : { $exists : true }
} ) ) === identities . length ,
120 ,
` the sessions of ' ${ roomName } ' to be active with statistics `
) ;
2026-11-02 20:15:49 +01:00
const participants = await mongo . findEntities ( Collection . PARTICIPANTS , {
roomName ,
identity : { $in : identities } ,
status : "active"
} ) ;
2026-10-15 20:47:40 +02:00
return participants . map ( ( participant ) = > participant . _id ) ;
}
/** The clients are gone with the server: no leave event will ever arrive. */
function dropClients() {
for ( const pid of pids ) {
killProcess ( pid , "SIGKILL" ) ;
}
pids = [ ] ;
}
2026-11-02 20:15:49 +01:00
/** 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 ) ;
}
2026-10-15 20:47:40 +02:00
/ * *
* Every session closed by the reconciler : left with the reason RECONCILED , an end time at most one statistics
2026-11-02 20:15:49 +01:00
* interval before the failure , one synthetic event each naming the closure reason , and no track or subscription
* of theirs left open .
2026-10-15 20:47:40 +02:00
* /
2026-11-02 20:15:49 +01:00
async function expectClosedByReconciler ( sids : string [ ] , failedAt : Date , reason = "node-dead" ) {
2026-10-15 20:47:40 +02:00
for ( const sid of sids ) {
const participant = await mongo . findEntity ( Collection . PARTICIPANTS , { _id : sid } ) ;
expect ( participant ) . toMatchObject ( { status : "left" , closedBy : "reconciler" , disconnectReason : "RECONCILED" } ) ;
const leftAt = ( participant ! . leftAt as Date ) . getTime ( ) ;
expect ( leftAt ) . toBeLessThanOrEqual ( failedAt . getTime ( ) ) ;
expect ( leftAt ) . toBeGreaterThanOrEqual ( failedAt . getTime ( ) - STATS_INTERVAL_MS ) ;
expect ( await mongo . findSyntheticEvent ( sid ) ) . toMatchObject ( {
type : "PARTICIPANT_LEFT" ,
participantSid : sid ,
2026-11-02 20:15:49 +01:00
synthetic : true ,
payload : { reconciler : { reason } }
2026-10-15 20:47:40 +02:00
} ) ;
}
expect ( await mongo . countOpen ( Collection . TRACKS , { participantSid : { $in : sids } } ) ) . toBe ( 0 ) ;
expect (
await mongo . countOpen ( Collection . TRACK_SUBSCRIPTIONS , {
$or : [ { subscriberSid : { $in : sids } } , { publisherSid : { $in : sids } } ]
} )
) . toBe ( 0 ) ;
}
2026-11-02 20:15:49 +01:00
/ * *
* 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 ;
}
2026-10-15 20:47:40 +02:00
it ( "closes the sessions and the room of a crashed server" , async ( ) = > {
const roomName = "ReconcilerCrash" ;
const sids = await startRoomWithPublishers ( roomName , [ "crash-alice" , "crash-bob" ] ) ;
const crashedAt = new Date ( ) ;
LocalDeployment . killContainer ( "openvidu" ) ;
dropClients ( ) ;
await sleep ( 5 ) ;
2026-11-02 20:15:49 +01:00
await serverReady ( crashedAt ) ;
2026-10-15 20:47:40 +02:00
await waitFor (
async ( ) = > ( await mongo . countOpen ( Collection . PARTICIPANTS , { _id : { $in : sids } } ) ) === 0 ,
CLOSURE_TIMEOUT_SECONDS ,
"the sessions of the crashed server to be closed"
) ;
await expectClosedByReconciler ( sids , crashedAt ) ;
await waitFor (
async ( ) = > ( await mongo . countOpen ( Collection . ROOMS , { name : roomName } ) ) === 0 ,
ROOM_CLOSURE_TIMEOUT_SECONDS ,
"the room of the crashed server to be closed"
) ;
2026-11-02 20:15:49 +01:00
const room = await mongo . findEntity ( Collection . ROOMS , { name : roomName } ) ;
expect ( room ) . toMatchObject ( { status : "closed" , closedBy : "reconciler" , endReason : "RECONCILED" } ) ;
// The room never ends before its sessions: it ends with the last one.
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 ;
2026-10-15 20:47:40 +02:00
} , 900000 ) ;
it ( "closes the sessions that a server stopped before its flush could not write" , async ( ) = > {
const sids = await startRoomWithPublishers ( "ReconcilerStop" , [ "stop-alice" , "stop-bob" ] ) ;
// SIGTERM, then SIGKILL one second later: the shutdown flush of the analytics queues (up to 5 s) is cut short.
const stoppedAt = new Date ( ) ;
LocalDeployment . stopContainerWithTimeout ( "openvidu" , 1 ) ;
dropClients ( ) ;
2026-11-02 20:15:49 +01:00
await serverReady ( stoppedAt ) ;
2026-10-15 20:47:40 +02:00
await waitFor (
async ( ) = > ( await mongo . countOpen ( Collection . PARTICIPANTS , { _id : { $in : sids } } ) ) === 0 ,
CLOSURE_TIMEOUT_SECONDS ,
"the sessions of the stopped server to be closed"
) ;
// A leave event that made it before the kill closes its session itself.
const reconciled = (
await mongo . findEntities ( Collection . PARTICIPANTS , { _id : { $in : sids } , closedBy : "reconciler" } )
) . map ( ( participant ) = > participant . _id ) ;
await expectClosedByReconciler ( reconciled , stoppedAt ) ;
expect ( await mongo . countOpen ( Collection . TRACKS , { participantSid : { $in : sids } } ) ) . toBe ( 0 ) ;
2026-11-02 20:15:49 +01:00
passed = true ;
} , 900000 ) ;
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 ;
2026-10-15 20:47:40 +02:00
} , 900000 ) ;
2026-11-02 20:15:49 +01:00
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 ( ) = > {
2026-10-15 20:47:40 +02:00
const roomName = "ReconcilerRedis" ;
const sids = await startRoomWithPublishers ( roomName , [ "redis-alice" , "redis-bob" ] ) ;
2026-11-02 20:15:49 +01:00
const template = ( await mongo . findEntity ( Collection . PARTICIPANTS , { _id : sids [ 0 ] } ) ) ! ;
2026-10-15 20:47:40 +02:00
const runBefore = await mongo . schemaMeta ( "reconciler" ) ;
2026-11-02 20:15:49 +01:00
// The deployment's Redis persists its data, so it comes back with LiveKit's listing; emptying it is a Redis
// restarted without its data.
2026-10-15 20:47:40 +02:00
const restartedAt = new Date ( ) ;
LocalDeployment . restartContainer ( "redis" ) ;
2026-11-02 20:15:49 +01:00
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 ) ;
2026-10-15 20:47:40 +02:00
2026-11-02 20:15:49 +01:00
const deadline = restartedAt . getTime ( ) + REDIS_RESTART_WATCH_SECONDS * 1000 ;
2026-10-15 20:47:40 +02:00
while ( Date . now ( ) < deadline ) {
expect ( await mongo . countOpen ( Collection . PARTICIPANTS , { _id : { $in : sids } } ) ) . toBe ( sids . length ) ;
await sleep ( 10 ) ;
}
expect ( await mongo . countOpen ( Collection . ROOMS , { name : roomName } ) ) . toBe ( 1 ) ;
const runAfter = await mongo . schemaMeta ( "reconciler" ) ;
expect ( ( runAfter ! . lastRunAt as Date ) . getTime ( ) ) . toBeGreaterThan (
Math . max ( restartedAt . getTime ( ) , ( runBefore ? . lastRunAt as Date | undefined ) ? . getTime ( ) ? ? 0 )
) ;
2026-11-02 20:15:49 +01:00
// 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 ) ;
2026-10-15 20:47:40 +02:00
for ( const pid of pids ) {
killProcess ( pid ) ;
}
pids = [ ] ;
await waitFor (
async ( ) = > ( await mongo . countOpen ( Collection . PARTICIPANTS , { _id : { $in : sids } } ) ) === 0 ,
120 ,
"the sessions to be closed by their leave events"
) ;
2026-11-02 20:15:49 +01:00
expect ( await mongo . count ( Collection . PARTICIPANTS , { _id : { $in : sids } , closedBy : "event" } ) ) . toBe ( sids . length ) ;
passed = true ;
2026-10-15 20:47:40 +02:00
} , 900000 ) ;
} ) ;