openvidu/openvidu-dashboard/openspec/specs/server-legacy-migration/spec.md

286 lines
17 KiB
Markdown

# server-legacy-migration Specification
## Purpose
Conversion of the legacy analytics collections (`events`, `stats`, `active_entities`, `last_alive`), written by
OpenVidu 3.9 and earlier, into the analytics model by the OpenVidu server, so that an upgraded deployment keeps its
history, closes the entities the legacy writer left open and exposes the progress to the dashboard.
## Requirements
### Requirement: Migration trigger at writer startup
Edition: CE and PRO. After renaming a legacy `events` collection and initializing the analytics collections, a writer
that finds no `schema_meta` `_id: "migration"` document and a legacy collection (`legacy_events`, `stats`,
`active_entities` or `last_alive`) SHALL write `_id: "migration"` with `state: "pending"` and start the migration in
the background; every later start resumes a migration that is not `completed`. Without legacy collections no
migration document SHALL be written.
#### Scenario: Legacy deployment upgraded
- **GIVEN** a database written by OpenVidu 3.9 or earlier and no `schema_meta` `_id: "schema"` document
- **WHEN** the writer starts
- **THEN** `legacy_events` holds the legacy documents with their indexes, the analytics collections exist, the live write path accepts events immediately and `schema_meta` `_id: "migration"` has `state: "pending"`, `total`, `writerVersion` and `fromSchemaVersion: 0`
#### Scenario: Fresh deployment
- **GIVEN** an empty database
- **WHEN** the writer starts
- **THEN** no `migration` document exists and nothing is renamed
#### Scenario: Legacy collection next to an analytics schema
- **GIVEN** a `schema_meta` `_id: "schema"` document written by the analytics writer, no `migration` document and a legacy `last_alive` collection
- **WHEN** the writer starts
- **THEN** `schema_meta` `_id: "migration"` is written with `state: "pending"`, whatever the recorded schema version
#### Scenario: Migration already completed
- **GIVEN** `schema_meta` `_id: "migration"` with `state: "completed"`
- **WHEN** the writer restarts
- **THEN** the document is unchanged and no replay runs
#### Scenario: Migration disabled
- **GIVEN** `openvidu.analytics.migration.enabled: false` and a pending migration
- **WHEN** the writer starts
- **THEN** the document stays `pending`, a warning names the setting and the live path runs normally
### Requirement: Migration state document
Edition: CE and PRO. `schema_meta` `_id: "migration"` SHALL carry `state` (`pending` | `running` |
`completed` | `failed`), `processed`, `total`, `checkpoint { phase, events, stats }`, `startedAt`,
`finishedAt`, `error`, `writerVersion`, `nodeId`, `counts { events, stats, synthetic, skipped }` and
`legacyRetainedUntil`, updated after every batch, so the dashboard can show progress without any other
access to the writer.
#### Scenario: Progress visible while running
- **GIVEN** a migration in progress
- **WHEN** `schema_meta` `_id: "migration"` is read
- **THEN** `state` is `running`, `processed` is at most one batch behind the documents applied, `total` is the document count of `legacy_events` plus `stats` at start, and `startedAt` and `nodeId` are set
#### Scenario: Completed with counts
- **GIVEN** a migration that finished all phases
- **WHEN** the document is read
- **THEN** `state` is `completed`, `finishedAt` is set, `counts` reports replayed events, stats, synthetic closures and skipped documents, and `legacyRetainedUntil` equals the latest `openvidu_expire_at` of the legacy collections
### Requirement: Single runner across nodes
Edition: CE and PRO. The migration SHALL run on at most one node at a time, elected with the Redis lock
`analytics-migration-lock` (TTL 60 s, refreshed after every batch; a process-local lock in a deployment without
Redis); a node that cannot obtain the lock SHALL serve the live path and retry the lock every minute, so it takes over
when the runner dies.
#### Scenario: Two nodes start together
- **GIVEN** a pending migration and two writer nodes starting at the same time
- **WHEN** both try to run it
- **THEN** exactly one runs, the other logs that another node holds the migration and both write live batches
#### Scenario: Runner node dies
- **GIVEN** a migration `running` on node A and node B waiting for the lock
- **WHEN** A crashes and its lock expires
- **THEN** node B obtains the lock at its next attempt, without a restart, and resumes from the stored checkpoint
### Requirement: Replay order and batching
Edition: CE and PRO. Legacy events SHALL be replayed in `timestamp.seconds` then `timestamp.nanos` order and stats in
`time_stamp.seconds` order, in batches of `batch_size` documents (fewer at the end) extended until the second of their
last document is complete, so that a batch never splits a second, read through a streaming cursor that holds one batch
in memory, with a pause after each batch so the throughput does not exceed `rate` documents per second.
#### Scenario: Throttle respected
- **GIVEN** `rate: 1000` and 10 000 legacy events
- **WHEN** the replay runs
- **THEN** it takes at least 10 s and live batches keep their interval during the run
#### Scenario: Batch keeps whole seconds
- **GIVEN** `batch_size: 500` and 700 events with the same `timestamp.seconds`
- **WHEN** the batch containing that second is read
- **THEN** all 700 events are in it and the checkpoint moves past that second only after they are applied
#### Scenario: Unthrottled
- **GIVEN** `rate: 0`
- **WHEN** the replay runs
- **THEN** no pause is inserted between batches
#### Scenario: Defaults
- **GIVEN** no `openvidu.analytics.migration` keys
- **WHEN** the replay runs
- **THEN** batches hold 500 documents, extended until the second of their last document is complete (fewer at the end), and the throughput stays at or below 2000 documents per second
### Requirement: Legacy document decoding
Edition: CE and PRO. Each legacy document SHALL be decoded into the protocol message the live writer
receives: numbers stored as doubles converted to the field's integer type, enums accepted as the enum
name or its number, oneof wrappers keyed by Go names, absent fields treated as zero values and
`ROOM_CREATED` without `room_id` resolved from `room.sid`. A document that cannot be decoded SHALL be
skipped, counted and logged with its `_id`.
#### Scenario: Float numbers
- **GIVEN** a legacy `ROOM_CREATED` with `room.creation_time: 1.728e9` and an `EGRESS_ENDED` with `egress.started_at` as a double of nanoseconds
- **WHEN** they are replayed
- **THEN** the room has `createdAt` as a `Date` and the egress `startedAt` and `durationMs` are exact to the millisecond
#### Scenario: Enums as names or numbers
- **GIVEN** a legacy `PARTICIPANT_LEFT` with `type: "PARTICIPANT_LEFT"`, `participant.kind: 4` and `participant.disconnect_reason: 1`
- **WHEN** it is replayed
- **THEN** the participant document has `kind: "EGRESS"` and `disconnectReason: "CLIENT_INITIATED"`
#### Scenario: Oneof by Go name
- **GIVEN** a legacy `EGRESS_STARTED` whose request is under `egress.Request.RoomComposite`
- **WHEN** it is replayed
- **THEN** the egress document has `type: "room_composite"` and a sanitized `request`
#### Scenario: ROOM_CREATED without room_id
- **GIVEN** a legacy `ROOM_CREATED` with `room.sid` and no `room_id`
- **WHEN** it is replayed
- **THEN** one `rooms` document with `_id` equal to `room.sid` exists
#### Scenario: Undecodable document
- **GIVEN** a legacy document whose `type` is unknown
- **WHEN** the batch is replayed
- **THEN** the document is skipped, `counts.skipped` increases, a warning carries its `_id` and the migration continues
### Requirement: Raw event identity
Edition: CE and PRO. A replayed legacy event SHALL be stored as a raw event whose `_id` is the legacy
`id` field (`AE_…`) when present and otherwise `AE_LEG_` followed by the legacy `_id`, so that a second
replay of the same document is a no-op; events of types not in `events.store_types` are projected but
not stored raw.
#### Scenario: Event with LiveKit id
- **GIVEN** a legacy event with `id: "AE_abc"`
- **WHEN** it is replayed twice
- **THEN** exactly one `events` document with `_id: "AE_abc"` exists and counters moved once
#### Scenario: Event without LiveKit id
- **GIVEN** a legacy event with string `_id` (md5) and no `id`
- **WHEN** it is replayed
- **THEN** the raw event `_id` is `AE_LEG_<md5>`
### Requirement: Fixer events become synthetic closures
Edition: CE and PRO. A legacy close event inserted by the active-entities fixer (ObjectId `_id`, no
`id`) SHALL not be projected as an authentic event: it SHALL close its entity through the reconciler's
closure routine at the fake event's timestamp with `closedBy: "reconciler"` and record a synthetic raw
event (`_id` `AE_SYN_` followed by the entity id). An authentic close event of the same entity SHALL override
the synthetic closure without counting it twice.
#### Scenario: Fake PARTICIPANT_LEFT
- **GIVEN** a legacy fake `PARTICIPANT_LEFT` for a participant whose `PARTICIPANT_ACTIVE` was replayed
- **WHEN** it is replayed
- **THEN** the participant has `status: "left"`, `closedBy: "reconciler"`, `disconnectReason: "RECONCILED"`, `leftAt` equal to the fake timestamp, and a raw event with `synthetic: true` and `payload.reconciler.reason: "legacy-fixer"` exists
#### Scenario: Fake EGRESS_ENDED claims COMPLETE
- **GIVEN** a legacy fake `EGRESS_ENDED` with `egress.status: "EGRESS_COMPLETE"`
- **WHEN** it is replayed
- **THEN** the egress is closed with `closedBy: "reconciler"` and the terminal status the reconciler uses for lost egresses, never `complete`
#### Scenario: Authentic close after fake close
- **GIVEN** a fake `ROOM_ENDED` and an authentic `ROOM_ENDED` for the same room
- **WHEN** both are replayed in any order
- **THEN** the room has `closedBy: "event"`, `endReason` from the authentic event and `endedAt` from it
### Requirement: Live events during the migration
Edition: CE and PRO. Live events SHALL be processed normally while the migration runs, and an entity
touched by both a replayed and a live event SHALL converge to the same document as if all events had
arrived live. Until the migration completes, the live reconciler SHALL not close candidates of nodes that never
wrote a heartbeat (legacy nodes), which the replay closes later.
#### Scenario: Room active across the upgrade
- **GIVEN** a room whose `ROOM_CREATED` and `PARTICIPANT_ACTIVE` are legacy and whose `PARTICIPANT_LEFT` and `ROOM_ENDED` arrive live during the replay
- **WHEN** the migration completes
- **THEN** one `rooms` document exists with `status: "closed"`, `closedBy: "event"`, `participants.sessions: 1` and `participants.active: 0`
#### Scenario: Live reconciler during the replay
- **GIVEN** a running migration, an open replayed legacy participant and a participant of a crashed node that wrote heartbeats
- **WHEN** the live reconciler runs
- **THEN** it closes the participant of the crashed node and leaves the legacy participant to the replay
### Requirement: Checkpoint and resume
Edition: CE and PRO. The checkpoint (`phase` and the last fully applied second of each collection) SHALL
be persisted after every batch; a restart, a lost lock or a failure SHALL leave a checkpoint from which
the next run resumes without reapplying any document. A non-transient error, or a transient one that persists
through five attempts, SHALL set `state: "failed"` with a clear `error`, and the next writer start SHALL retry from
the checkpoint.
#### Scenario: Killed mid-run
- **GIVEN** a migration killed after 40 % of the events
- **WHEN** the writer restarts
- **THEN** the replay resumes after the checkpointed second, no `track_stats` sample is duplicated and the final documents equal those of an uninterrupted run
#### Scenario: Database error
- **GIVEN** a MongoDB outage during a batch
- **WHEN** the batch fails five attempts in a row, spaced 1, 2, 4 and 8 s
- **THEN** `state` is `failed`, `error` names the failure and the checkpoint, and the next writer start retries from the checkpoint
#### Scenario: Transient error with progress
- **GIVEN** a phase that fails with a network error four times, applies more batches and fails again
- **WHEN** it is retried
- **THEN** the attempt count starts again after the progress and the migration keeps `state: "running"`
### Requirement: Stats replay
Edition: CE and PRO. Legacy `stats` SHALL be replayed after the events through the live stats pipeline,
producing `track_stats` documents and the same participant, room and rollup deltas the live writer
produces, in the configured stats mode.
#### Scenario: Legacy stat document
- **GIVEN** a legacy stat with `kind: "UPSTREAM"`, `time_stamp` as doubles and `streams[]` RTP totals
- **WHEN** it is replayed
- **THEN** a `track_stats` document exists with `ts` as a `Date`, `meta.kind: "up"`, normalized units and the participant's `bytes.up` includes its bytes
### Requirement: Post-replay steps
Edition: CE and PRO. After the stats phase the migration SHALL run the reconciler until a run is not partial and
closes nothing (at most 20 runs), drop `active_entities` and `last_alive`, record `legacyRetainedUntil` and mark
the migration `completed`; `legacy_events` and `stats` SHALL never be deleted by the migration.
#### Scenario: Open entities after the replay
- **GIVEN** a participant whose legacy `PARTICIPANT_LEFT` never existed and whose room is absent from LiveKit
- **WHEN** the post-replay reconciliation runs
- **THEN** the participant is `left` with `closedBy: "reconciler"` and a room still listed by LiveKit stays `active`
#### Scenario: Legacy collections afterwards
- **GIVEN** a completed migration
- **WHEN** the database is inspected
- **THEN** `active_entities` and `last_alive` do not exist, `legacy_events` and `stats` keep their documents and TTL indexes, and `legacyRetainedUntil` is set
### Requirement: Legacy data kept for rollback
Edition: CE and PRO. The migration SHALL only read `legacy_events` and `stats`, never changing their documents or
indexes, so that a legacy server (OpenVidu 3.9 or earlier) can be restored on the legacy data that has not expired
by its TTL.
#### Scenario: Rollback after the migration
- **GIVEN** a completed migration
- **WHEN** the operator stops the server, drops the analytics collections, renames `legacy_events` back to `events` and starts the legacy server
- **THEN** `events` and `stats` hold every legacy document whose `openvidu_expire_at` has not passed, with their original indexes
### Requirement: Observability
Edition: CE and PRO. The writer SHALL expose `openvidu_analytics_migration_state` (gauge: 0 none, 1 pending,
2 running, 3 completed, 4 failed), `openvidu_analytics_migration_processed_total{collection}` and
`openvidu_analytics_migration_batch_seconds` (batch duration histogram) and SHALL
log progress (processed, total, rate, estimated remaining time) at least every 30 s while running and
one line per state transition.
#### Scenario: Metrics during the run
- **GIVEN** a running migration
- **WHEN** the metrics endpoint is scraped
- **THEN** `openvidu_analytics_migration_state` is 2 and `openvidu_analytics_migration_processed_total` grows with the replay
### Requirement: Migration invariants
Edition: CE and PRO. After completion the analytics collections SHALL satisfy: one `rooms` document per distinct
legacy `room.sid`, one `participants` document per distinct legacy `participant_id`, one `egresses`
document per `egress_id`, one `ingress_sessions` document per `ingress.state.resource_id`, no secret
field copied, and durations of event-closed entities within 1 s of the legacy start/end difference.
#### Scenario: Invariants on a recorded snapshot
- **GIVEN** a legacy snapshot recorded from the local deployment
- **WHEN** the migration completes and the invariant checks run
- **THEN** every check passes and a second migration of the legacy events of the same snapshot leaves every document unchanged
### Requirement: Room-name search field on migrated egresses
Edition: CE and PRO. A migrated egress document SHALL carry `roomNameLower`, the lower-cased legacy room name, set by
the live egress projection that replays the legacy egress events; a legacy egress without a room name SHALL get
neither `roomName` nor `roomNameLower`. Node documents are not migrated.
#### Scenario: Legacy egress of a room
- **GIVEN** a legacy `EGRESS_STARTED` whose `egress.room_name` is `Demo-Room`
- **WHEN** it is replayed
- **THEN** the `egresses` document has `roomName: "Demo-Room"` and `roomNameLower: "demo-room"`
#### Scenario: Egress closed by a fixer event
- **GIVEN** a legacy egress of room `Demo-Room` whose only close event is a fake `EGRESS_ENDED` of the active-entities fixer
- **WHEN** the migration completes
- **THEN** the egress is closed by the reconciler and keeps `roomNameLower: "demo-room"` from its replayed `EGRESS_STARTED`
#### Scenario: Legacy web egress
- **GIVEN** a legacy web egress with empty `room_name`
- **WHEN** it is replayed
- **THEN** the `egresses` document has neither `roomName` nor `roomNameLower`