23 KiB
server-reconciler Specification
Purpose
Closes rooms, participants, tracks, subscriptions, egress and ingress sessions that stay active in the analytics store because their close events never arrived, using the store's own liveness signals and LiveKit's live state, so the dashboard never shows entities that no longer exist.
Requirements
Requirement: Participant candidate detection
Edition: CE and PRO servers. The reconciler SHALL treat a participant session as a candidate when it is
open (joining or active) and lastStatsAt is older than 3 × stats interval (90 s), or when it has no stats at
all and activeAt (or joinedAt when the session never became active) is older than the same period.
Scenario: Stats stopped
- GIVEN an
activeparticipant whoselastStatsAtis 100 s old - WHEN a reconciler run starts
- THEN the participant is a candidate and
openvidu_analytics_reconciler_candidates{entity="participant"}counts it
Scenario: Never received stats
- GIVEN an
activeparticipant with nolastStatsAtwhoseactiveAtis 95 s old - WHEN a reconciler run starts
- THEN the participant is a candidate
Scenario: Within the grace period
- GIVEN an
activeparticipant with nolastStatsAtwhoseactiveAtis 40 s old - WHEN a reconciler run starts
- THEN the participant is not a candidate
Requirement: Room candidate detection
Edition: CE and PRO servers. The reconciler SHALL treat as a candidate an active room that received no stats for 3 ×
stats interval and either has no open participant session of a kind that keeps a room open (every kind but EGRESS and
AGENT, which LiveKit treats as dependent) and a lastEventAt older than its empty timeout (default 300 s) plus 2 ×
reconciler interval, or is hosted by a dead node and received no event for 3 × stats interval, whatever its open
sessions.
Scenario: Empty room past its timeout
- GIVEN an
activeroom with empty timeout 300 s, no active STANDARD participants andlastEventAtmore than 7 minutes old - WHEN a reconciler run starts
- THEN the room is a candidate
Scenario: Room kept open by a participant
- GIVEN an
activeroom on an alive node with oneactiveSTANDARD participant andlastEventAt20 minutes old - WHEN a reconciler run starts
- THEN the room is not a candidate
Scenario: Room with only dependent participants
- GIVEN an
activeroom whose only active participant has kind EGRESS andlastEventAtolder than its empty timeout plus the grace - WHEN a reconciler run starts
- THEN the room is a candidate
Scenario: Room of a dead node
- GIVEN an
activeroom on a dead node, created 2 minutes ago with empty timeout 300 s, whose last stats flush and last event are 100 s old and whose only STANDARD participant isactive - WHEN a reconciler run starts
- THEN the room is a candidate
Scenario: Room of an alive node within its timeout
- GIVEN an
activeroom on an alive node with empty timeout 300 s and neither sessions, stats nor events for 2 minutes - WHEN a reconciler run starts
- THEN the room is not a candidate
Requirement: Egress and ingress session candidate detection
Edition: CE and PRO servers. The reconciler SHALL treat an egress or an ingress session as a candidate only when it
has not ended (egress starting, active or ending; ingress session buffering or publishing), its room is
closed in the store for longer than 2 × LiveKit cleanup interval, and the room's node is dead; egress and ingress of
active rooms or alive nodes are never candidates.
Scenario: Egress of a closed room on a dead node
- GIVEN an
activeegress whose room isclosedsince 7 minutes withnodeIdof a dead node - WHEN a reconciler run starts
- THEN the egress is a candidate
Scenario: Egress of a closed room on an alive node
- GIVEN an
activeegress whose room isclosedsince 10 minutes on a node that wrote a batch 5 s ago - WHEN a reconciler run starts
- THEN the egress is not a candidate and is left to the server-side reconciler
Scenario: Ingress session of an active room
- GIVEN an
activeingress session whose room isactive - WHEN a reconciler run starts
- THEN the ingress session is not a candidate
Requirement: Confirmation by node liveness
Edition: CE and PRO servers. A node SHALL be considered dead when nodes.lastSeenAt is older than 2 × writer batch
interval and LiveKit's node registry does not report it available. A participant session hosted by a dead node SHALL
be confirmed without the live listing. A room hosted by a dead node SHALL be confirmed when a trusted live listing does
not list it with its SID, or when its last signal is older than 2 × LiveKit cleanup interval.
Scenario: Node stopped writing batches
- GIVEN a candidate participant whose node has
lastSeenAt45 s old and is absent from LiveKit's node registry - WHEN the candidate is checked
- THEN it is confirmed with reason
node-deadeven if the live listing contains it
Scenario: Node alive by registry
- GIVEN a candidate participant whose node has
lastSeenAt45 s old but LiveKit's node registry reports it available - WHEN the candidate is checked
- THEN the node is treated as alive and the live listing decides
Scenario: Room still held by LiveKit
- GIVEN a candidate room of a dead node whose last signal is 2 minutes old and that a trusted live listing still lists with its SID, before LiveKit's dead node cleanup removed it
- WHEN the candidate is checked
- THEN it is not confirmed, it stays
activeand a debug log line names it
Scenario: Room removed by the dead node cleanup
- GIVEN the same room after LiveKit's dead node cleanup removed it, so that the trusted live listing no longer lists its SID
- WHEN the next run checks it
- THEN it is confirmed with reason
node-dead
Scenario: Room recreated with another SID
- GIVEN a candidate room of a dead node and a trusted live listing that lists a room with the same name and another SID
- WHEN the candidate is checked
- THEN it is confirmed with reason
node-dead
Scenario: Room never removed by the cleanup
- GIVEN a candidate room of a dead node with the default cleanup interval of 3 minutes, whose last signal is 7 minutes old and that the live listing still lists or cannot be read
- WHEN the candidate is checked
- THEN it is confirmed with reason
node-dead
Scenario: Untrusted listing
- GIVEN a candidate room of a dead node whose last signal is 2 minutes old and a live listing that returns an error
- WHEN the run executes
- THEN the room stays
activeand the participant sessions of the dead node are closed
Requirement: Confirmation by live state when the node is alive
Edition: CE and PRO servers. When a candidate's node is alive, the reconciler SHALL confirm it only if the entity is absent from LiveKit's live listing (rooms and participants read through the shared store) on two consecutive runs; a candidate present in the listing SHALL be kept and logged.
Scenario: Absent twice
- GIVEN a candidate participant whose node is alive and which is absent from the live listing in two consecutive runs
- WHEN the second run checks it
- THEN it is confirmed with reason
absent-live
Scenario: Absent once
- GIVEN a candidate participant whose node is alive and which was absent from the live listing for the first time in this run
- WHEN the run ends
- THEN the participant stays
active
Scenario: Present in live state
- GIVEN a candidate participant whose node is alive and which is listed in the live state
- WHEN the candidate is checked
- THEN it is not closed and a warning names the participant and its stale
lastStatsAt
Requirement: Untrusted live listing
Edition: CE and PRO servers. A live listing SHALL be untrusted and confirm nothing when reading it fails, or when it lists no room while active rooms of alive nodes received stats within the last 3 × stats interval (the state right after a Redis restart). Observations from an untrusted listing SHALL not count towards consecutive absences.
Scenario: Redis error
- GIVEN candidates whose nodes are alive and a live listing that returns an error
- WHEN the run executes
- THEN no entity is closed,
openvidu_analytics_reconciler_runs_total{result="live-state-error"}increases and the next run starts the absence count from zero
Scenario: Empty listing after a Redis restart
- GIVEN a Redis restart that wiped the room state while active rooms of alive nodes keep receiving stats
- WHEN the next run executes
- THEN the listing is treated as untrusted, no entity is closed and
openvidu_analytics_reconciler_runs_total{result="live-state-untrusted"}increases
Requirement: Fresh stats veto
Edition: CE and PRO servers. The reconciler SHALL never close a room or participant session whose lastStatsAt is
within the last stats interval at closure time: each closure is a conditional update that applies only while the
entity keeps the status the run read and has no such stats flush (and, for a room, no event since the run read it).
Scenario: Stats arrive between detection and closure
- GIVEN a confirmed candidate participant
- WHEN a stats flush updates its
lastStatsAtbefore the closure is written - THEN the closure is skipped and the participant stays
active
Scenario: Healthy entities during a Redis restart
- GIVEN connected participants that keep sending stats every 30 s while Redis is restarted
- WHEN reconciler runs execute during the following 4 minutes
- THEN none of them is closed
Requirement: Participant closure cascade
Edition: CE and PRO servers. Closing a participant session SHALL set its published tracks to
unpublished and the open subscriptions to those tracks, and the participant's own open subscriptions, to
unsubscribed, all with the participant's end time and closedBy: "reconciler".
Scenario: Publisher with subscribers
- GIVEN a confirmed participant with two published tracks subscribed by three other participants
- WHEN it is closed
- THEN the participant is
left, its two tracks areunpublishedwithunpublishedAt = leftAt, the six subscriptions areunsubscribedwithunsubscribedAt = leftAt, and all carryclosedBy: "reconciler"
Scenario: Session that never became active
- GIVEN a confirmed candidate with status
joining - WHEN it is closed
- THEN its status is
abortedwithdisconnectReason: "RECONCILED"
Requirement: Room closure cascade
Edition: CE and PRO servers. Closing a room SHALL close its open participant sessions (active or
joining) with the room's end time, applying the participant cascade to each of them.
Scenario: Room with a stale dependent participant
- GIVEN a confirmed room with one
activeEGRESS participant - WHEN it is closed
- THEN the room is
closedwithendReason: "RECONCILED"and the participant isleftwithleftAt = endedAtanddisconnectReason: "RECONCILED"
Requirement: Rooms of a dead node close with their last session
Edition: CE and PRO servers. A confirmed room of a dead node SHALL be closed in the same run as the closures that leave
it without an open session of a kind that keeps a room open, after them, with reason node-dead and its last signal
as end time, so that it never ends before its sessions; a session the run does not close SHALL keep the room open until
a later run.
Scenario: Closed with its last session
- GIVEN a node killed while hosting a room created 2 minutes earlier with two STANDARD participants, and LiveKit's dead node cleanup already removed the room
- WHEN the run that closes both participants executes
- THEN in the same run the room is
closedwithendReason: "RECONCILED",closedBy: "reconciler", reasonnode-deadin its closure log and synthetic event, andendedAtequal to the laterleftAtof the two participants
Scenario: Closure limit reached before the last session
- GIVEN a confirmed room of a dead node with three open STANDARD sessions and
reconciler.max_closures_per_run: 2 - WHEN runs execute
- THEN the first run closes two sessions, leaves the room
activeand is recorded as partial, and the follow-up run closes the third session and the room
Scenario: Closed after the dead node cleanup
- GIVEN a room of a dead node whose sessions a previous run closed while LiveKit still listed the room
- WHEN the first run after the dead node cleanup removed it executes
- THEN the room is
closedwithendedAtequal to theleftAtof its last session, not the time of the run
Requirement: Closure fields
Edition: CE and PRO servers. A closure SHALL set the terminal status, closedBy: "reconciler", endReason
or disconnectReason RECONCILED, the end time (max(lastStatsAt, lastEventAt) for rooms and participant sessions,
inherited by cascaded entities; for egress and ingress sessions, as in their store fallback), durationMs, and
expiresAt from the retention of its class (retention.tracks for tracks and subscriptions, retention.entities
otherwise).
Scenario: End time from the last signal
- GIVEN a confirmed participant with
lastStatsAt10:00:30 andlastEventAt10:00:05 - WHEN it is closed
- THEN
leftAtis 10:00:30,durationMs = leftAt - joinedAtandexpiresAt = leftAt + retention.entities
Scenario: Bounded timestamp error
- GIVEN a node killed at T while its participants had stats flushed at most 30 s earlier
- WHEN the reconciler closes them
- THEN every
leftAtis within 30 s of T
Scenario: Cascaded tracks expire with the tracks retention
- GIVEN a confirmed participant with one published track and
retention.tracks: 168h - WHEN it is closed
- THEN the track has
expiresAt = unpublishedAt + 168hand the participantexpiresAt = leftAt + retention.entities
Requirement: Synthetic raw event per closure
Edition: CE and PRO servers. Every closure of a room, participant session, egress or ingress session, including the
participant sessions a room closure cascades to, SHALL insert exactly one raw event of the matching close type with
synthetic: true, _id prefixed AE_SYN_ and derived from the entity id, the entity's lifted references and the
closure reason in its payload.
Scenario: Participant closure event
- GIVEN a participant closed by the reconciler
- WHEN
eventsis queried by itsparticipantSid - THEN one
PARTICIPANT_LEFTdocument exists withsynthetic: true,_id: "AE_SYN_PA_…",ts = leftAt,payload.participant.disconnect_reason: "RECONCILED"and the closure reason inpayload.reconciler.reason
Scenario: Closure repeated
- GIVEN a closure already recorded
- WHEN the same closure is applied again
- THEN no second synthetic event exists
Requirement: Counters and rollups once per closure
Edition: CE and PRO servers. Closures SHALL update room and participant counters and the rollups through
the writer's idempotent projections, exactly once per entity; a real close event that arrives after a
synthetic closure SHALL replace the reconciler's end time, reason and closedBy without counting the
closure twice.
Scenario: Room counters after a cascade
- GIVEN a room with three active participants of which two are closed by the reconciler
- WHEN the room document is read
- THEN
participants.activeis 1 andanalytics_hourlycounts twoleftparticipants with reasonRECONCILED
Scenario: Late real close event
- GIVEN a participant closed by the reconciler
- WHEN its real
PARTICIPANT_LEFTis processed later - THEN
leftAt,disconnectReasonandclosedBy: "event"come from the real event, the status staysleftand the rollupleftcounter is unchanged
Requirement: Lost egress and ingress representation
Edition: CE and PRO servers. Egress and ingress lost with their node SHALL be closed by the real
EGRESS_ENDED (FAILED) and INGRESS_ENDED events of the server-side reconciler, projected as failed
and error; the store reconciler SHALL close only candidate egress and ingress sessions, always as
failed or error, never complete.
Scenario: Egress lost after the room is gone
- GIVEN an egress whose handler died and whose room was deleted
- WHEN the server-side reconciler emits
EGRESS_ENDEDwith status FAILED and error "egress lost" - THEN the egress document has
status: "failed",errorfrom the event andclosedBy: "event"
Scenario: Store fallback for an egress
- GIVEN a candidate egress (room closed for more than 2 × cleanup interval, node dead)
- WHEN the reconciler closes it
- THEN it has
status: "failed",error: "egress lost (reconciled)",closedBy: "reconciler",endedAt = max(room.endedAt, egress.updatedAt)and a syntheticEGRESS_ENDEDevent exists
Scenario: Store fallback for an ingress session
- GIVEN a candidate ingress session
- WHEN the reconciler closes it
- THEN it has
status: "error",error: "ingress lost (reconciled)",closedBy: "reconciler",endedAt = max(room.endedAt, session.startedAt)and its ingress document reflects the ended session
Requirement: Leader-only periodic runs
Edition: CE and PRO servers. Runs SHALL execute on one node at a time, chosen by a Redis lock
(analytics-reconciler-lock, or the local mutex without Redis), every reconciler.interval (default 60 s); a lock lost
in a Redis restart SHALL be re-acquired without manual intervention.
Scenario: Two nodes
- GIVEN two server nodes with analytics enabled
- WHEN 5 minutes pass
- THEN
schema_meta(_id: "reconciler") shows about five runs and everylastRunAtcomes from a single node at a time
Scenario: Redis restart
- GIVEN a leader holding the lock
- WHEN Redis restarts
- THEN within 2 ×
reconciler.intervala node holds the lock again and runs resume
Requirement: Reconciler switch
Edition: CE and PRO servers. With openvidu.analytics.reconciler.enabled: false (default true) a node SHALL skip
the periodic and startup runs and SHALL log at startup that entities whose end event is lost stay open; the legacy
migration runs its own post-replay reconciliation whatever this setting.
Scenario: Reconciler disabled
- GIVEN
reconciler.enabled: falseand a participant of a crashed nodeactivein the store - WHEN 5 minutes pass
- THEN the participant stays
active,schema_metahas no newreconcilerrun, no dailyuniqueIdentitiesis set and the startup log says that lost entities stay open
Requirement: Daily unique identities
Edition: CE and PRO servers. After its closures, within its time budget, each run SHALL set uniqueIdentities on
the daily rollups (global and per node: distinct identities of the STANDARD sessions that joined that UTC day) of up
to 7 days whose global document lacks it, from 5 minutes after the day closes and only for days inside the entities
retention; a node scope without sessions that day gets 0.
Scenario: Day closed
- GIVEN a global daily document of yesterday without
uniqueIdentities, STANDARD sessions ofalicetwice andbobon noden1, andcarolon noden2 - WHEN a run executes after 00:05 UTC today
- THEN the global document has
uniqueIdentities: 3,node:n12 andnode:n21
Scenario: Day just closed
- GIVEN a run at 00:03 UTC
- WHEN it evaluates the previous day
- THEN that day's
uniqueIdentitiesis not set yet
Requirement: Startup run
Edition: CE and PRO servers. A node SHALL execute one run right after the writer finishes initializing the database (collections and indexes present), subject to the same lock, so a restarted single node closes the sessions of its previous incarnation without waiting a full interval.
Scenario: Single node restarted after a long outage
- GIVEN a single node that was down for 10 minutes with 5 active participants in the store
- WHEN it starts
- THEN the 5 participants are closed within
reconciler.intervalof the start
Requirement: Idempotent and bounded runs
Edition: CE and PRO servers. A run SHALL be idempotent, SHALL close at most
openvidu.analytics.reconciler.max_closures_per_run entities (default 500) within the lock's time budget, and when
the limit or the budget is reached SHALL record the run as partial and run a follow-up 5 s later, without waiting
for the next interval.
Scenario: Run repeated
- GIVEN a run that closed 10 entities
- WHEN the same run executes again on the same data
- THEN no document changes and
openvidu_analytics_reconciler_closed_totalis unchanged
Scenario: Mass crash
- GIVEN 1 200 confirmed candidates
- WHEN runs execute
- THEN the first run closes 500, records
partial: true, and the remainder is closed by follow-up runs within 2 minutes
Requirement: Metrics
Edition: CE and PRO servers. The node SHALL expose openvidu_analytics_reconciler_runs_total{result},
openvidu_analytics_reconciler_closed_total{entity}, openvidu_analytics_reconciler_candidates{entity} and
openvidu_analytics_reconciler_run_duration_seconds on its Prometheus endpoint, with result in ok, partial,
live-state-error, live-state-untrusted, error, and entity in room, participant, track, subscription, egress,
ingress_session (no track or subscription candidates).
Scenario: After a closure
- GIVEN a run that closed one participant with one track and two subscriptions
- WHEN the metrics endpoint is scraped
- THEN
openvidu_analytics_reconciler_closed_total{entity="participant"}is 1,{entity="track"}1,{entity="subscription"}2 andopenvidu_analytics_reconciler_runs_total{result="ok"}increased by 1
Requirement: Last run record
Edition: CE and PRO servers. Every run SHALL upsert schema_meta (_id: "reconciler") with
lastRunAt, lastRunDurationMs, nodeId, candidates and closed counts per entity, partial, and
lastError when the run failed.
Scenario: Record of a run
- GIVEN a completed run
- WHEN
schema_metais read - THEN the
reconcilerdocument has the counts of that run andlastRunAtwithin the lastreconciler.interval
Requirement: Structured closure logs
Edition: CE and PRO servers. Every closure of a room, participant session, egress or ingress session SHALL emit one
info log line with the entity type and id, room, node, reason (node-dead, absent-live, cascade,
room-closed-node-dead, legacy-fixer), lastStatsAt and lastEventAt when known and the end time written; kept
rooms SHALL be logged at debug level and kept participant sessions that LiveKit lists in one warning per run.
Scenario: Closure log
- GIVEN a participant closed with reason
node-dead - WHEN the server log is read
- THEN one line contains the participant sid, its room sid, its node id,
reason=node-deadand theleftAtwritten