Skip to content

Commit 75f306e

Browse files
committed
Document RaceSession backfill contract
Define the resumable Room-to-RaceSession backfill, reconciliation, privacy, and rollback requirements before the PostgreSQL aggregate cutover.
1 parent 353e79a commit 75f306e

3 files changed

Lines changed: 199 additions & 0 deletions

File tree

docs/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ This directory contains the durable technical documentation for Let's Cube.
1212
reconnects, and result delivery
1313
- [Data and migrations](data.md) — MongoDB, PostgreSQL, Prisma, metrics, and
1414
persistence guarantees
15+
- [Room-to-RaceSession backfill and reconciliation](race-session-backfill.md)
16+
— resumable migration, parity, privacy, and rollback contract
1517
- [Production operations](operations.md) — deployment, health checks, rollback,
1618
backups, restore, and capacity management
1719

docs/data.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,3 +142,8 @@ Before a data change, identify:
142142

143143
Moving reads to PostgreSQL or making PostgreSQL required is a separate migration
144144
decision and must not happen incidentally as part of a schema change.
145+
146+
The planned Room-to-RaceSession cutover has a separate
147+
[backfill and reconciliation contract](race-session-backfill.md). It requires
148+
resumable, privacy-safe verification before PostgreSQL becomes authoritative
149+
for rooms, race sessions, attempts, and solves.

docs/race-session-backfill.md

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
# Room-to-RaceSession backfill and reconciliation contract
2+
3+
This document defines the safe migration contract for issue #175. It is a
4+
specification only: it adds no migration, command, MongoDB write, or production
5+
operation. It applies after the additive `RaceSession` schema is available and
6+
before PostgreSQL becomes authoritative for the complete room aggregate.
7+
8+
The [data migration guide](data.md) describes the current MongoDB-backed
9+
dual-write phase. The target Room and RaceSession ownership contract is a
10+
prerequisite for implementing this specification.
11+
12+
## Scope and source boundary
13+
14+
At the start of this work, MongoDB is authoritative. A legacy Mongo room maps
15+
to exactly one PostgreSQL Room and exactly one initial RaceSession, including
16+
its current attempts, results, and participant state. The backfill must not
17+
infer, synthesize, or claim to recover events that a prior legacy event change
18+
removed from the embedded `attempts` array. This is an intentional data-loss
19+
boundary of the legacy model, not a reconciliation mismatch.
20+
21+
The backfill copies only the room aggregate:
22+
23+
| Source | Target |
24+
| --- | --- |
25+
| Mongo `Room` metadata | `app.rooms` |
26+
| current Mongo `event`, `started`, `startTime`, and `nextSolveAt` | one initial `app.race_sessions` row |
27+
| Mongo users, bans, and room-level presence/membership maps | `app.room_participants` |
28+
| Mongo competing, waiting, and registration maps | `app.session_participants` for the initial session |
29+
| Mongo embedded attempts | `app.attempts` for the initial session |
30+
| Mongo attempt results | `app.solves` for those attempts |
31+
32+
The implementation must document an explicit projection for every legacy
33+
field. A field with no target owner is either left in Mongo for the compatible
34+
runtime or is intentionally excluded; it must not be silently attached to a
35+
different aggregate.
36+
37+
## Deterministic identity
38+
39+
The existing `server/postgres/dualWrite.js` UUIDv5 namespace and `stableId`
40+
helper are the identity authority during the compatibility phase. The
41+
implementation must use it consistently and must not generate random IDs
42+
during a retry or a subsequent backfill run.
43+
44+
| Target record | Required deterministic key |
45+
| --- | --- |
46+
| Room | `stableId('room', mongoRoomId)` |
47+
| initial RaceSession | `stableId('race-session', mongoRoomId)` |
48+
| RoomParticipant | `(roomId, stableId('user', wcaUserId))` |
49+
| SessionParticipant | `(raceSessionId, stableId('user', wcaUserId))` |
50+
| Attempt | the existing `stableId('attempt', attemptMongoId)` key |
51+
| Solve | the existing `stableId('solve', attemptMongoId + ':' + wcaUserId)` key |
52+
53+
`attemptMongoId` retains the current compatibility rule: use the embedded
54+
attempt `_id` when present; otherwise derive the documented fallback from room,
55+
event, creation time, and ordinal. The new session relation must preserve the
56+
existing attempt and solve IDs, so a schema expansion links rows rather than
57+
re-ingesting duplicate results.
58+
59+
The initial RaceSession is identified by the room Mongo ID only because it is
60+
the one session reconstructed from the one current legacy room. New sessions
61+
created after the cutover receive a different, durable session identity from
62+
the Room/RaceSession service; they are never conflated with this initial
63+
backfill session.
64+
65+
## Resumable execution
66+
67+
The eventual command must default to dry-run. It must expose an explicit
68+
`--apply` mode, bounded batch size, and a supplied or newly created run ID. A
69+
run checkpoint is durable PostgreSQL migration state, not process memory or a
70+
terminal log. It records at least the schema version, source filter, batch
71+
size, last completed source cursor, completed-batch count, started/finished
72+
times, and terminal status.
73+
74+
Rooms are read in a stable Mongo `_id` order. A completed batch has committed
75+
every target row for its complete room aggregate, including attempts and solves,
76+
or has no checkpoint advancement. The writer uses one PostgreSQL transaction
77+
per source room. Re-running a committed batch is safe because all identities
78+
are deterministic and upserts compare source revisions.
79+
80+
A failed room records a redacted failure classification and retry count without
81+
advancing its cursor. A resumed run retries that room before later work. A run
82+
may skip a permanently invalid source row only with an explicit operator
83+
decision recorded in the final report; it cannot silently count that row as
84+
complete. Batches must be small enough that a retry does not hold a transaction
85+
open while reading another room.
86+
87+
## Live-write no-loss rule
88+
89+
Backfill alone cannot guarantee a consistent result while rooms are racing.
90+
Before the first apply run, the compatible room write path must mirror every
91+
full aggregate mutation to the expanded PostgreSQL model and record a durable,
92+
monotonic source change watermark. The watermark must cover room creation,
93+
membership/moderation changes, session/racing state, attempt creation, and
94+
solve submission.
95+
96+
The implementation performs the following sequence:
97+
98+
1. Enable and verify the compatible live writer without changing read
99+
authority.
100+
2. Record a source watermark, backfill every room up to that boundary, then
101+
drain and apply all later durable change records.
102+
3. Repeat the drain until a parity pass observes no unapplied change records.
103+
4. Keep the live writer enabled through the shadow-read observation window.
104+
105+
An acknowledgement for a live mutation is valid only after its source write and
106+
the compatible target write/change record meet this protocol. A transient
107+
PostgreSQL error therefore leaves the room marked for retry and visible in
108+
operational health; it must not be hidden by declaring the reconciliation
109+
complete. The exact outbox or revision mechanism is an implementation decision,
110+
but an in-memory queue, wall-clock timestamp alone, or best-effort log scan is
111+
not sufficient.
112+
113+
## Parity and reports
114+
115+
Reconciliation is read-only against both stores. It compares canonical source
116+
projections with PostgreSQL by deterministic identity and emits machine-readable
117+
JSON plus an aggregate operator summary. A clean report contains zero
118+
unexplained differences for:
119+
120+
- Room metadata and lifecycle projection;
121+
- exactly one initial RaceSession for every mapped legacy room;
122+
- room and session participants, including moderation and racing state;
123+
- attempts, ordinal, event, and immutable scramble content; and
124+
- solves, user, time, penalties, and source timestamps.
125+
126+
Every difference has a category: `missing_target`, `unexpected_target`,
127+
`identity_mismatch`, `field_mismatch`, `invalid_source`, `unapplied_change`, or
128+
`legacy_history_unrecoverable`. The final category is informational only when
129+
it identifies legacy event history absent from Mongo; it cannot hide a missing
130+
current attempt or solve. The report includes input/cursor bounds, counts by
131+
category, retry status, and a pseudonymous HMAC identifier for any source row
132+
requiring investigation. It does not include raw source documents.
133+
134+
Before changing read authority, two consecutive full parity passes must be
135+
clean with no unapplied live changes between the report watermarks. A separate
136+
shadow-read comparison then verifies the PostgreSQL projection against the
137+
Mongo response for the unchanged normal-room flow. Any mismatch blocks cutover
138+
and is remediated by an idempotent retry or an explicitly documented data fix,
139+
followed by a new full parity pass.
140+
141+
## Fixture specification
142+
143+
The implementation test suite must use synthetic fixtures only and cover these
144+
cases:
145+
146+
| Fixture | Required assertion |
147+
| --- | --- |
148+
| normal room with current event, two attempts, and accepted/penalized solves | creates one initial RaceSession; preserves deterministic room, attempt, and solve IDs across two apply runs |
149+
| room with no embedded attempts | creates its initial session and participants without manufacturing an attempt or solve |
150+
| legacy room after an event change | maps only its current event and embedded attempts; reports no invented earlier event session |
151+
| room with an embedded attempt lacking `_id` | uses the documented fallback identity and remains idempotent |
152+
| mixed participant maps, including a banned user | keeps room moderation separate from initial session racing state |
153+
| interrupted batch followed by resume | leaves no partial aggregate checkpoint, retries the failed room, and reaches the same target as an uninterrupted run |
154+
| live solve or attempt after the baseline watermark | is applied during drain and appears in the clean parity report |
155+
| malformed source row or failed target transaction | records a redacted retryable failure and never advances the cursor as if it succeeded |
156+
| target-only stale row | appears as `unexpected_target`; the backfill does not delete it automatically |
157+
158+
Fixtures may use synthetic scramble text to validate persistence, but report
159+
fixtures assert only scramble count and a test-only hash. No fixture, snapshot,
160+
or report may contain a real room name, password, access code, WCA ID, user
161+
name, email address, OAuth/session value, or production scramble.
162+
163+
## Privacy and access constraints
164+
165+
The command reads no email fields and must neither query nor use them for
166+
matching. It uses numeric WCA user IDs only to derive internal deterministic
167+
user IDs. Logs, checkpoints, metrics, and reports must exclude email addresses,
168+
names, WCA IDs, room names, passwords, access codes, raw scrambles, solve
169+
times, chat, authentication/session data, and Mongo documents. Troubleshooting
170+
uses categorized counts and keyed pseudonymous identifiers only.
171+
172+
Backfill and parity operate with database credentials, not user-facing room
173+
access. They preserve private-room access policy in the target data and never
174+
turn a private room into a discoverable result merely by copying it.
175+
176+
## Rollback compatibility
177+
178+
This phase is additive. It does not write MongoDB, delete MongoDB embedded
179+
attempts/results, remove PostgreSQL rows, or switch application reads. The
180+
immediately previous application image must tolerate the expanded schema and
181+
ignore the new migration-run records.
182+
183+
If rollout is paused or rolled back before the aggregate cutover, stop the new
184+
PostgreSQL writer, keep MongoDB as the source of truth, retain the additive
185+
schema and checkpoints, and preserve the change records needed to reconcile
186+
later writes. Do not reverse a committed database migration or purge target
187+
history as part of rollback. Before a new cutover attempt, resume the backfill,
188+
drain live changes, and require clean parity again.
189+
190+
MongoDB writes for room/session/result domains are removed only in the later
191+
complete-aggregate cutover, after the rollback observation window. This
192+
backfill contract does not authorize that removal.

0 commit comments

Comments
 (0)