Skip to content

Commit 2d09346

Browse files
andrescrzclaude
andcommitted
fix(cutover): gate rollback stages B/C on --accept-post-cutover-write-loss
Address Baz #919 (high, rollback safety). Promoting the frozen pre-cutover backup makes traces the successor accepted after cutover_start non-live. They are not destroyed — the successor is parked as traces_local_v2 until finalize.sh, recoverable during the soak — but the live table stops serving them. This is inherent to promoting a point-in-time backup and is deliberately not auto-repaired (merging the successor's post-cutover writes back would re-import the data the rollback exists to discard). - rollback.sh: stages B/C now require --accept-post-cutover-write-loss and print the recovery pointer before the promote. Named --accept-* (not --confirm-*) because it acknowledges an unavoidable consequence rather than a precondition the operator fixes. - README: scope the "no data loss" claim to "no data-bearing table dropped"; add the post-cutover-write caveat with the recovery path; show the flag in the B/C examples. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 35c8173 commit 2d09346

2 files changed

Lines changed: 52 additions & 17 deletions

File tree

apps/opik-backend/data-migrations/traces-local-v2-cutover/README.md

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -419,26 +419,36 @@ as one file per stage (`000004_rollback_stage_a_discard_shadow.sql`, `…_stage_
419419
`…_stage_c_promote_original.sql`, and the shared `000004_rollback_reverse_replay.sql`) and driven by
420420
[`scripts/rollback.sh`](scripts/rollback.sh), so no one authors it under pressure.
421421

422-
**No data loss by construction.** The stages are mutually exclusive, so each lives in its **own file** — no single file
423-
mixes the `TRUNCATE` (stage A only) with the `EXCHANGE`/`DROP` of the others, and running any file does exactly one
424-
stage. No statement drops a data-bearing table: swaps are atomic `EXCHANGE`/`RENAME`, and the only `DROP` targets the
425-
`Distributed` wrapper, which stores no data (it is a routing definition over `traces_local`). Before running, `rollback.sh`
426-
**asserts the live `traces` topology matches the requested stage and aborts otherwise** — so a wrong-stage run (the only
427-
way a `TRUNCATE`/`DROP` could hit the wrong table) makes no change. Every stage lands in the same **canonical state**:
428-
`traces` = the original data (live), `traces_local_v2` = the successor data (parked backup). No leftover `*_new` names.
429-
The parked backup is dropped only later, by `finalize.sh`, after the soak.
422+
**No data-bearing table is dropped by construction.** The stages are mutually exclusive, so each lives in its **own
423+
file** — no single file mixes the `TRUNCATE` (stage A only) with the `EXCHANGE`/`DROP` of the others, and running any
424+
file does exactly one stage. No statement drops a data-bearing table: swaps are atomic `EXCHANGE`/`RENAME`, and the only
425+
`DROP` targets the `Distributed` wrapper, which stores no data (it is a routing definition over `traces_local`). Before
426+
running, `rollback.sh` **asserts the live `traces` topology matches the requested stage and aborts otherwise** — so a
427+
wrong-stage run (the only way a `TRUNCATE`/`DROP` could hit the wrong table) makes no change. Every stage lands in the
428+
same **canonical state**: `traces` = the original data (live), `traces_local_v2` = the successor data (parked backup).
429+
No leftover `*_new` names. The parked backup is dropped only later, by `finalize.sh`, after the soak.
430+
431+
> **Stages B/C make post-cutover writes non-live — an accepted, acknowledged trade-off.** Promoting the frozen
432+
> `traces_pre_cutover_backup` means traces the successor accepted **after** `cutover_start` stop being served by the live
433+
> table (the reverse-replay carries post-cutover *deletes* forward, but not *writes*). They are **not destroyed**: the
434+
> successor is parked as `traces_local_v2` and retained until `finalize.sh`, so recover them from there during the soak
435+
> if the rollback is later judged unnecessary. This is inherent to promoting a point-in-time backup and is *not* auto-repaired
436+
> — merging the successor's post-cutover writes back would re-import the very data the rollback exists to discard. Because
437+
> it is irreversible in the moment, stages B/C require `--accept-post-cutover-write-loss`, and `rollback.sh` prints the
438+
> recovery pointer before the promote.
430439
431440
Pick the stage by how far the cutover got (`cutover_start` is the value `exchange_and_wrap.sh` printed):
432441

433442
- **Stage A — before EXCHANGE:** `./scripts/rollback.sh --database opik --stage A`. Discards the disposable shadow
434443
`traces_local_v2`; the live `traces` was never touched. (Guarded: aborts unless `traces` is still the original schema.)
435-
- **Stage B — after EXCHANGE, before wrap:** `./scripts/rollback.sh --database opik --stage B --cutover-start '<ts>'`.
436-
`EXCHANGE` `traces_pre_cutover_backup` back to live `traces`, rename the now-parked successor back to
437-
`traces_local_v2`, then the reverse replay. (Guarded: aborts if `traces` is `Distributed` — use C.)
438-
- **Stage C — after wrap:** `./scripts/rollback.sh --database opik --stage C --cutover-start '<ts>'`. Drops the
439-
`Distributed` wrapper, then one atomic `RENAME` promotes the original (`traces_pre_cutover_backup`) back to `traces`
440-
and parks the successor under `traces_local_v2`, then the reverse replay. (Guarded: aborts unless `traces` is
441-
`Distributed`.)
444+
- **Stage B — after EXCHANGE, before wrap:** `./scripts/rollback.sh --database opik --stage B --cutover-start '<ts>'
445+
--confirm-retention-paused --accept-post-cutover-write-loss`. `EXCHANGE` `traces_pre_cutover_backup` back to live
446+
`traces`, rename the now-parked successor back to `traces_local_v2`, then the reverse replay. (Guarded: aborts if
447+
`traces` is `Distributed` — use C.)
448+
- **Stage C — after wrap:** `./scripts/rollback.sh --database opik --stage C --cutover-start '<ts>'
449+
--confirm-retention-paused --accept-post-cutover-write-loss`. Drops the `Distributed` wrapper, then one atomic
450+
`RENAME` promotes the original (`traces_pre_cutover_backup`) back to `traces` and parks the successor under
451+
`traces_local_v2`, then the reverse replay. (Guarded: aborts unless `traces` is `Distributed`.)
442452

443453
After a stage B or C rollback, `traces` is the Nullable original again — **revert `traceColumnsNonNullable` to `false`
444454
AND roll-restart every backend instance**. The flag is read from a **startup snapshot** of `OpikConfiguration` (bound via

apps/opik-backend/data-migrations/traces-local-v2-cutover/scripts/rollback.sh

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,17 @@
77
# --stage A backfill/delta ran but the EXCHANGE did not — discard the shadow (live `traces` is untouched).
88
# --stage B the EXCHANGE ran but not the wrap — swap the tables back, then reverse-replay.
99
# --stage C the wrap ran — drop the wrapper, promote the parked original, then reverse-replay.
10-
# Stages B and C need --cutover-start (printed by exchange_and_wrap.sh) to bound the reverse-replay, and
10+
# Stages B and C need --cutover-start (printed by exchange_and_wrap.sh) to bound the reverse-replay,
1111
# --confirm-retention-paused (retention deletes bypass the bridge, so a retention sweep in the rollback window would
12-
# resurrect a deleted row from the backup). Keep the deletion bridge enabled through the rollback so no delete is lost.
12+
# resurrect a deleted row from the backup), and --accept-post-cutover-write-loss (see below). Keep the deletion bridge
13+
# enabled through the rollback so no delete is lost.
14+
#
15+
# POST-CUTOVER WRITES: stages B/C promote the frozen pre-cutover backup back to live `traces`, so traces WRITTEN to the
16+
# successor after cutover_start stop being live. They are NOT destroyed — the successor is parked as traces_local_v2 and
17+
# retained until finalize.sh, so they can be recovered from there during the soak — but the live table no longer serves
18+
# them. This is inherent to promoting a point-in-time backup and cannot be "fixed" (auto-merging the successor's writes
19+
# would re-import the very data the rollback is discarding); --accept-post-cutover-write-loss makes the operator
20+
# acknowledge it before the promote.
1321
#
1422
# SAFETY: the stages are mutually exclusive and each lives in its OWN file, so no single file mixes a TRUNCATE with an
1523
# EXCHANGE/DROP — running any file does exactly one stage. Before running, this asserts the live `traces` topology matches
@@ -28,13 +36,15 @@ DATABASE=""
2836
STAGE=""
2937
CUTOVER_START=""
3038
CONFIRM_RETENTION_PAUSED=0
39+
ACCEPT_WRITE_LOSS=0
3140

3241
while [[ $# -gt 0 ]]; do
3342
case "$1" in
3443
--database) DATABASE="${2:?"$1 requires a value"}"; shift 2 ;;
3544
--stage) STAGE="${2:?"$1 requires a value"}"; shift 2 ;;
3645
--cutover-start) CUTOVER_START="${2:?"$1 requires a value"}"; shift 2 ;;
3746
--confirm-retention-paused) CONFIRM_RETENTION_PAUSED=1; shift ;;
47+
--accept-post-cutover-write-loss) ACCEPT_WRITE_LOSS=1; shift ;;
3848
*) echo "Unknown argument: $1" >&2; exit 2 ;;
3949
esac
4050
done
@@ -57,6 +67,16 @@ if [[ ( "$STAGE" == "B" || "$STAGE" == "C" ) && "$CONFIRM_RETENTION_PAUSED" != "
5767
echo " Pause retention (RETENTION_ENABLED=false on every backend), then re-run with the flag." >&2
5868
exit 2
5969
fi
70+
# Stages B/C promote the frozen pre-cutover backup, so writes the successor accepted after cutover_start stop being live
71+
# (they are preserved in the parked traces_local_v2 until finalize.sh, recoverable during the soak). This is unavoidable
72+
# when promoting a point-in-time backup — require the operator to acknowledge it, unlike a precondition they could fix.
73+
if [[ ( "$STAGE" == "B" || "$STAGE" == "C" ) && "$ACCEPT_WRITE_LOSS" != "1" ]]; then
74+
echo "ERROR: rollback --stage $STAGE requires --accept-post-cutover-write-loss. Promoting the frozen backup makes" >&2
75+
echo " traces written to the successor after cutover_start non-live. They are NOT destroyed — the successor is" >&2
76+
echo " parked as traces_local_v2 until finalize.sh, so recover them from there during the soak — but the live" >&2
77+
echo " table will no longer serve them. Re-run with the flag once you accept this." >&2
78+
exit 2
79+
fi
6080

6181
ch() {
6282
clickhouse-client --database "$DATABASE" --log_comment 'traces_local_v2_rollback' --query "$1"
@@ -124,6 +144,11 @@ run_file() {
124144

125145
assert_topology
126146

147+
if [[ "$STAGE" == "B" || "$STAGE" == "C" ]]; then
148+
echo "NOTE: promoting the frozen backup now. Traces the successor accepted after cutover_start ($CUTOVER_START) will" >&2
149+
echo " stop being live; recover them from the parked traces_local_v2 (kept until finalize.sh) if needed." >&2
150+
fi
151+
127152
case "$STAGE" in
128153
A)
129154
run_file 000004_rollback_stage_a_discard_shadow.sql

0 commit comments

Comments
 (0)