Skip to content

Commit 1ac4f20

Browse files
committed
[OPIK-7315] [BE] fix(cutover): bound the post-rollback compare by cutover_start, and correct what a sealed-week mismatch means
Two problems with pointing the post-rollback and retry compares at '--to-week last-sealed'. It bounds by the calendar, but the divergence is bounded by the cutover window. Those are the same week only while the verify runs promptly; run it in any later week and the window's own week counts as sealed, so the writes the rollback deliberately discarded read as a fidelity failure. rollback.sh now computes and prints the offset of the last week wholly before cutover_start, which does not drift, and the runbook points at that instead of deriving one by hand. The query is advisory and runs after the rollback has already succeeded, so it is non-fatal: a blip prints instructions rather than aborting and swallowing the remaining steps. The stated rule - that a mismatch in a sealed week is the real signal - was also too strong, in three places. Any write touching a PRE-EXISTING trace after cutover_start diverges it in a sealed week, which no weekly bound can exclude, because the divergence sits where the row was born rather than where the write happened. Two shapes, confirmed locally: the trace-update endpoint keeps the row's created_at, so the key differs on both sides; batch ingestion re-stamps it, so the key goes missing from the successor in its original week. Both are the discarded-write class. Document the triage that separates them from a real copy gap - look the differing ids up in the successor without a week filter, and treat last_updated_at >= cutover_start as benign - and carry the same caveat into the post-EXCHANGE note, which asserted the same rule.
1 parent 004d1f1 commit 1ac4f20

2 files changed

Lines changed: 68 additions & 16 deletions

File tree

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

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1112,18 +1112,25 @@ it revives writes the rollback chose to discard. Run it only with the guards bel
11121112
corruption in the reused shadow is caught exactly as in the first cutover — so do not skip it on the grounds that the
11131113
data "was already verified once".
11141114

1115-
**Bound it to the sealed weeks, and expect the current week to differ.** The reused shadow is a *superset* of the
1116-
restored original by exactly the revived writes from (3), so an unbounded run reports them and looks like a fidelity
1117-
failure on a perfectly good retry. This is the same shape, and the same `--to-week last-sealed` remedy, as the post-rollback
1118-
compare above — including the direction: the **new-table** side is the superset here. A mismatch in a *sealed* week is
1119-
the real signal.
1115+
**Bound it before the first cutover's window, and expect that week to differ.** The reused shadow is a *superset* of
1116+
the restored original by exactly the revived writes from (3) — and those sit in the week the **original**
1117+
`cutover_start` fell in, so an unbounded run reports them and looks like a fidelity failure on a perfectly good
1118+
retry. Same shape and direction as the post-rollback compare above (the **new-table** side is the superset), so reuse
1119+
the offset `rollback.sh` printed then: the last week wholly before that `cutover_start`. It also covers the live
1120+
current week, which the restored original keeps writing to while the delta catches up.
1121+
1122+
`--to-week last-sealed` is the wrong token here — it tracks the calendar, so a retry run in any later week stops
1123+
excluding the window's own week. And the same caveat carries over: a write that touched a pre-existing trace during
1124+
the first window diverges it in a *sealed* week, which no weekly bound excludes. Triage it the same way — look the
1125+
differing ids up in the shadow without a week filter, and treat `last_updated_at >= cutover_start` as benign.
11201126

11211127
Note the flags from (4) do not change what `verify.sh` compares: it normalizes both sentinel and `NULL` absent-values
11221128
to the same fingerprint, so it passes either way. It cannot catch a missed flag flip — only a positive read-back check
11231129
can (write an in-progress trace, assert `end_time` is null), which is why (4) is a step and not a caveat here.
11241130

11251131
```bash
1126-
./scripts/verify.sh --database opik --to-week last-sealed # old=traces, new=traces_local_v2 (the defaults)
1132+
# N = the offset rollback.sh printed; old=traces, new=traces_local_v2 (the defaults)
1133+
./scripts/verify.sh --database opik --to-week <N>
11271134
```
11281135

11291136
If any of that does not hold, take the supported path: `finalize.sh` to recycle the backup into a clean shadow, then a
@@ -1232,23 +1239,45 @@ exists (the successor is parked as `traces_post_rollback_backup`), so a bare `ve
12321239
parked successor:
12331240

12341241
```bash
1235-
./scripts/verify.sh --database opik --old-table traces --new-table traces_post_rollback_backup --to-week last-sealed
1242+
# rollback.sh prints this command with the bound already computed — prefer that over deriving the offset by hand.
1243+
./scripts/verify.sh --database opik --old-table traces --new-table traces_post_rollback_backup --to-week <N>
12361244
```
12371245

1238-
Expect the **sealed historical weeks to match** and the **current week to mismatch**, by exactly the post-cutover writes
1239-
the rollback discarded (the parked successor holds them; the restored original never did) — so bound the run with
1240-
`--to-week last-sealed`, exactly as for the post-EXCHANGE compare. A mismatch in a *sealed* week would be
1241-
the real signal. Note the divergence is the **opposite** direction from the post-EXCHANGE case: here the *new-table* side
1242-
is the superset.
1246+
Expect the **cutover window's own week to mismatch**, by exactly the post-cutover writes the rollback discarded (the
1247+
parked successor holds them; the restored original never did) — so stop before it. Note the divergence is the
1248+
**opposite** direction from the post-EXCHANGE case: here the *new-table* side is the superset.
1249+
1250+
**Bound this one by `cutover_start`, not by the calendar.** `--to-week last-sealed` drops the current calendar week,
1251+
which is the window's week only while the verify runs promptly; run it in a later week and the window's week counts as
1252+
sealed, so its discarded writes read as a fidelity failure. `rollback.sh` prints the offset of the last week wholly
1253+
before `cutover_start`, which does not drift — use it.
1254+
1255+
**And a mismatch inside the bound is not automatically corruption.** Any write that touches a **pre-existing** trace
1256+
after `cutover_start` diverges it in a *sealed* week, which no weekly bound can exclude — the divergence sits where the
1257+
row was born, not where the write happened. Two mechanisms, opposite in shape:
1258+
1259+
- the **trace-update endpoint** keeps the row's original `created_at`, so the successor holds a newer version in that
1260+
row's own week — the key differs **on both sides**;
1261+
- **batch ingestion** re-stamps `created_at` to now, so the successor's latest version moves to a later week — the key
1262+
goes **missing from the successor** in its original week (`--drill-down` prints `\N` for that side).
1263+
1264+
Both are the discarded-write class, not a fidelity defect. Triage with `--drill-down`, then look each differing id up in
1265+
the parked successor **without** a week filter: `last_updated_at >= cutover_start` means benign. A key that is absent
1266+
from the successor *entirely* is the real signal — that is a copy gap, and it is the one shape worth stopping for. How
1267+
often this bites tracks how much pre-existing data the workload rewrites; for many it is none, which is why the weekly
1268+
bound is still worth passing.
12431269

12441270
> **The pre-EXCHANGE compare is the gate; the post-EXCHANGE compare has a caveat.** `traces_pre_cutover_backup` is a
12451271
> **frozen** snapshot as of `cutover_start`, but live `traces` keeps taking writes the instant the buffer drains — so
12461272
> the **current (live) week will legitimately show a mismatch** (the live table is a superset of the frozen backup by
12471273
> exactly the post-cutover writes). That is expected, not a leak. To use the post-EXCHANGE compare as a real check,
12481274
> either run it **immediately after the swap before writes resume**, or bound it to the **sealed historical weeks** with
12491275
> `--to-week N` (a **0-based week offset** from the anchor Monday, not a date — e.g. `--to-week 3` to stop before the
1250-
> current partial week), where a mismatch *would* be a genuine problem. A leak shows up as rows present in the
1251-
> backup but absent from `traces`; post-cutover writes are the harmless opposite direction.
1276+
> current partial week), where a mismatch is worth investigating — with one exception in the same class as the
1277+
> post-cutover writes: a write touching a **pre-existing** trace after `cutover_start` diverges it in that row's own
1278+
> week, sealed or not (see "Verifying after a rollback" for the two shapes and the triage). Check
1279+
> `last_updated_at >= cutover_start` on the differing ids before calling it a defect. A leak shows up as rows present in
1280+
> the backup but absent from `traces` *and* absent from it entirely; post-cutover writes are the harmless direction.
12521281
12531282
**Feasibility at scale.** A full pass reads every partition (heavy but bounded per week — run off-peak). When that is
12541283
infeasible, sample and still get high confidence:

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

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -375,9 +375,32 @@ esac
375375

376376
if [[ "$STAGE" == "B" || "$STAGE" == "C" ]]; then
377377
echo "Now in the canonical state: traces = original data (live), traces_post_rollback_backup = successor data (parked)."
378+
# The divergence is bounded by the CUTOVER WINDOW, not by the calendar, so compute the offset of the last week wholly
379+
# before cutover_start rather than telling the operator 'last-sealed'. That token drops the current calendar week,
380+
# which is the same week only while the verify runs promptly; a run in a later week would include the window's own
381+
# week and report its discarded writes as a fidelity failure. Same anchor math verify.sh uses on this table.
382+
# Non-fatal: the rollback itself has already succeeded here, so a blip computing an advisory number must not abort
383+
# the script and swallow the NEXT steps below.
384+
bound_week="$(ch "SELECT dateDiff('week', toMonday(min(created_at)), toMonday(toDateTime64('$CUTOVER_START', 6))) - 1 FROM traces" 2>/dev/null || true)"
378385
echo "Verify with the POST-ROLLBACK table pair — the verify.sh defaults no longer apply (traces_local_v2 is gone), and"
379-
echo "the CURRENT week legitimately mismatches by the post-cutover writes this rollback discarded, so bound it:"
380-
echo " ./verify.sh --database $DATABASE --old-table traces --new-table traces_post_rollback_backup --to-week last-sealed"
386+
echo "the cutover window's week legitimately mismatches by the post-cutover writes this rollback discarded, so stop"
387+
echo "before it. That week is fixed by cutover_start, so the bound does not drift if you verify later:"
388+
if [[ "$bound_week" =~ ^-?[0-9]+$ ]] && (( bound_week >= 0 )); then
389+
echo " ./verify.sh --database $DATABASE --old-table traces --new-table traces_post_rollback_backup --to-week $bound_week"
390+
elif [[ "$bound_week" =~ ^-?[0-9]+$ ]]; then
391+
echo " (none: every row in 'traces' predates no earlier week than the cutover window's own, so there is nothing"
392+
echo " to compare below it. Skip the bounded compare.)"
393+
else
394+
echo " ./verify.sh --database $DATABASE --old-table traces --new-table traces_post_rollback_backup --to-week <N>"
395+
echo " where N could not be computed just now: it is the whole weeks between toMonday(min(created_at)) on"
396+
echo " 'traces' and cutover_start's Monday, minus 1."
397+
fi
398+
echo "A mismatch inside that bound is NOT automatically corruption. Any write touching a PRE-EXISTING trace after"
399+
echo "cutover_start diverges it in a sealed week, which no weekly bound excludes: the update endpoint keeps the row's"
400+
echo "created_at (so the key differs on both sides), while batch ingestion re-stamps it (so the key goes missing from"
401+
echo "the successor in its original week). Triage with --drill-down, then look each differing id up in the successor"
402+
echo "WITHOUT a week filter: last_updated_at >= cutover_start means benign. Absent from the successor entirely is the"
403+
echo "real signal — that one is a copy gap."
381404
echo "Then run finalize.sh once healthy — it recycles the backup into an empty traces_local_v2 (restoring the"
382405
echo "pre-cutover shadow), the one irreversible step."
383406
echo

0 commit comments

Comments
 (0)