- **GIVEN** an `active` room 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 is `active`
- **GIVEN** a candidate participant whose node has `lastSeenAt` 45 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 `active` and 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
- **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
- **THEN** the listing is treated as untrusted, no entity is closed and `openvidu_analytics_reconciler_runs_total{result="live-state-untrusted"}` increases
`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 are `unpublished` with `unpublishedAt = leftAt`, the six subscriptions are `unsubscribed` with `unsubscribedAt = leftAt`, and all carry `closedBy: "reconciler"`
#### Scenario: Session that never became active
- **GIVEN** a confirmed candidate with status `joining`
- **WHEN** it is closed
- **THEN** its status is `aborted` with `disconnectReason: "RECONCILED"`
`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 `active` EGRESS participant
- **WHEN** it is closed
- **THEN** the room is `closed` with `endReason: "RECONCILED"` and the participant is `left` with `leftAt = endedAt` and `disconnectReason: "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 `closed` with `endReason: "RECONCILED"`, `closedBy: "reconciler"`, reason `node-dead` in its closure log and synthetic event, and `endedAt` equal to the later `leftAt` of 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 `active` and 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 `closed` with `endedAt` equal to the `leftAt` of 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`
- **GIVEN** a participant closed by the reconciler
- **WHEN** `events` is queried by its `participantSid`
- **THEN** one `PARTICIPANT_LEFT` document exists with `synthetic: true`, `_id: "AE_SYN_PA_…"`, `ts = leftAt`, `payload.participant.disconnect_reason: "RECONCILED"` and the closure reason in `payload.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.active` is 1 and `analytics_hourly` counts two `left` participants with reason `RECONCILED`
#### Scenario: Late real close event
- **GIVEN** a participant closed by the reconciler
- **WHEN** its real `PARTICIPANT_LEFT` is processed later
- **THEN** `leftAt`, `disconnectReason` and `closedBy: "event"` come from the real event, the status stays `left` and the rollup `left` counter 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_ENDED` with status FAILED and error "egress lost"
- **THEN** the egress document has `status: "failed"`, `error` from the event and `closedBy: "event"`
#### Scenario: Ingress lost after the room is gone
- **GIVEN** a publishing ingress session whose ingress service died and whose room was deleted
- **WHEN** the server-side reconciler resets its state to `ENDPOINT_INACTIVE` with error "ingress lost" and emits `INGRESS_ENDED`
- **THEN** the ingress session has `status: "error"`, `error: "ingress lost"` and `closedBy: "event"`, and the ingress is `inactive`, ready for another session
- **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 synthetic `EGRESS_ENDED` event exists
#### Scenario: Store fallback for an ingress session
- **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
- **THEN** the participant stays `active`, `schema_meta` has no new `reconciler` run, no daily `uniqueIdentities` is set and the startup log says that lost entities stay open
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 of `alice` twice and `bob` on node `n1`, and `carol` on node `n2`
- **WHEN** a run executes after 00:05 UTC today
- **THEN** the global document has `uniqueIdentities: 3`, `node:n1` 2 and `node:n2` 1
#### Scenario: Day just closed
- **GIVEN** a run at 00:03 UTC
- **WHEN** it evaluates the previous day
- **THEN** that day's `uniqueIdentities` is 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.interval` of 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
- **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 and `openvidu_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_meta` is read
- **THEN** the `reconciler` document has the counts of that run and `lastRunAt` within the last `reconciler.interval`