Skip to content

Commit 8349610

Browse files
andrescrzclaude
andcommitted
docs(cutover): frame cross-replica ON CLUSTER skew for multi-replica production + maintenance note
The prior comment-precision pass described the cross-host ON CLUSTER case as "a non-issue in the default single-node deployment", which downplays it: production is multi-replica. Correct the framing in the stage-B/C rollback SQL, finalize.sh and the runbook, and add the operator maintenance note. - Cross-replica: ON CLUSTER runs synchronously (the client blocks until every reachable replica applies it, or throws naming a laggard that then converges via the DDL queue), so there is no durable mixed topology — only a sub-second cross-replica skew as it propagates, the same accepted skew as the wrap. Not globally atomic (no ClickHouse primitive for that); nil on a single replica. - Rollback stages B/C rename the LIVE `traces`, so reads hit the skew: rollback.sh now prints a maintenance NOTE and the runbook says to run the rollback in a maintenance moment / with reads quiesced on a multi-replica cluster. - finalize is EXEMPT: its TRUNCATE+RENAME touch only the parked backup / disposable shadow, never the live `traces`, so there is no live-read skew and no maintenance window is needed — stated explicitly in finalize.sh and the runbook. Comment/doc-only; no behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f5eff68 commit 8349610

5 files changed

Lines changed: 27 additions & 11 deletions

File tree

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -481,6 +481,14 @@ Pick the stage by how far the cutover got (`cutover_start` is the value `exchang
481481
`RENAME` promotes the original (`traces_pre_cutover_backup`) back to `traces` and parks the successor as
482482
`traces_post_rollback_backup`, then the reverse replay. (Guarded: aborts unless `traces` is `Distributed`.)
483483

484+
> **Multi-replica note (production is multi-replica).** Stages B and C promote via a single `ON CLUSTER` RENAME of the
485+
> **live** `traces`. It runs synchronously across the shard's replicas — the client blocks until each applies it, or fails
486+
> loudly naming a laggard, which then converges via the DDL queue — so there is no durable mixed topology, only a brief
487+
> sub-second cross-replica skew as it propagates, during which a read on a not-yet-renamed replica sees the pre-rollback
488+
> `traces`. This is the same accepted `ON CLUSTER` skew as the wrap; on a multi-replica cluster run the rollback in a
489+
> maintenance moment / with reads quiesced. `finalize.sh` is **exempt** — it renames only the parked backup / disposable
490+
> shadow, never the live `traces`, so it has no live-read skew and needs no maintenance window.
491+
484492
**Recovering from an interrupted rollback.** Each promote stage runs its table-swap and then the reverse-replay as two
485493
statements, so a failure *between* them needs a restart path:
486494

apps/opik-backend/data-migrations/traces-local-v2-cutover/scripts/db-app-analytics/000004_rollback_stage_b_exchange_back.sql

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@
88
-- restoring the canonical state: traces = original live, traces_post_rollback_backup = successor parked. Gapless and with
99
-- no orphan risk: the multi-target RENAME is one statement, atomic PER HOST (all clauses apply or none), so on any node
1010
-- there is no window where a partial failure strands the successor under a wrong name — the flaw of a separate
11-
-- EXCHANGE + RENAME, which has that window even on a single host. ACROSS hosts, ON CLUSTER is serialized through the DDL
12-
-- queue and eventually-consistent, NOT globally atomic: a briefly lagging host converges on its own (or the ON CLUSTER
13-
-- call fails loudly naming it) — the same accepted skew as any ON CLUSTER DDL, and a non-issue in the default single-node
14-
-- deployment (one host). Non-destructive.
11+
-- EXCHANGE + RENAME, which has that window even on a single host. ACROSS the shard's replicas (production is multi-replica)
12+
-- ON CLUSTER runs synchronously — the client blocks until every reachable replica applies it, or throws naming a laggard
13+
-- that then converges via the DDL queue — so there is no durable mixed topology, only a sub-second cross-replica skew as
14+
-- it propagates, during which a read on a not-yet-renamed replica sees the old `traces`. That is the same accepted
15+
-- ON CLUSTER skew as the wrap (nil on a single replica); run this in the rollback maintenance moment (see the runbook).
16+
-- Non-destructive.
1517
-- rollback.sh runs the reverse-replay (000004_rollback_reverse_replay.sql) right after this so deletes since
1618
-- cutover_start do not resurrect. rollback.sh asserts the post-EXCHANGE, pre-wrap topology (traces = successor schema,
1719
-- not Distributed) before running it.

apps/opik-backend/data-migrations/traces-local-v2-cutover/scripts/db-app-analytics/000004_rollback_stage_c_promote_original.sql

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@
88
-- `traces` (the name freed by the first clause), and the successor shard (`traces_local`) parks as
99
-- `traces_post_rollback_backup` (a retained backup, dropped only by finalize.sh — NOT the disposable `traces_local_v2`
1010
-- shadow) — ending in the canonical state (traces = original live, traces_post_rollback_backup = successor parked). So
11-
-- `traces` is never absent on a node. ACROSS hosts, as for any ON CLUSTER DDL, the rename is eventually-consistent (a
12-
-- lagging host converges on its own, or the call fails loudly naming it), NOT globally atomic — a non-issue in the
13-
-- default single-node deployment (one host).
11+
-- `traces` is never absent on a node. ACROSS the shard's replicas (production is multi-replica) ON CLUSTER runs
12+
-- synchronously — the client blocks until every reachable replica applies it, or throws naming a laggard that then
13+
-- converges via the DDL queue — NOT globally atomic, so the only exposure is a sub-second cross-replica skew as it
14+
-- propagates, during which a read on a not-yet-renamed replica sees the pre-rollback `traces` (the same accepted
15+
-- ON CLUSTER skew as the wrap; nil on a single replica). Run this in the rollback maintenance moment (see the runbook).
1416
--
1517
-- Then drop the ex-wrapper. It is dropped under `traces_dist_old` — a fresh name that ONLY the data-less wrapper ever
1618
-- occupied — so the DROP cannot hit the original data regardless of per-replica DDL timing (the concern with dropping a

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,10 +89,11 @@ if [[ "$BACKUP" == "traces_post_rollback_backup" ]]; then
8989
# is fixed at CREATE and unchanged by the rename) — back into an empty `traces_local_v2`. ClickHouse has no single
9090
# truncate-and-rename, so this is two statements, each atomic PER HOST and ON CLUSTER; ordered TRUNCATE-then-RENAME so
9191
# the only state a crash between them can leave is an empty `traces_post_rollback_backup`, which re-running finalize
92-
# recovers (RENAME-first could strand a populated `traces_local_v2` that a retry backfill would mis-skip). ACROSS hosts,
93-
# ON CLUSTER is eventually-consistent, NOT globally atomic — the same accepted skew as any ON CLUSTER DDL and a
94-
# non-issue in the default single-node deployment (one host): a lagging host converges on its own via the DDL queue,
95-
# or the ON CLUSTER call fails loudly naming it.
92+
# recovers (RENAME-first could strand a populated `traces_local_v2` that a retry backfill would mis-skip). ACROSS the
93+
# shard's replicas ON CLUSTER runs synchronously (the client blocks until every reachable replica applies it, or throws
94+
# naming a laggard that then converges via the DDL queue), NOT globally atomic. Both statements touch only the parked
95+
# backup / disposable shadow — never the live `traces` — so unlike the rollback promote and the wrap (which rename live
96+
# `traces`) the brief cross-replica skew is invisible to readers, and finalize needs no maintenance window.
9697
if [[ "$CONFIRM" != "1" ]]; then
9798
echo "DRY RUN: would recycle $DATABASE.$BACKUP into an empty $DATABASE.traces_local_v2 (TRUNCATE + RENAME)."
9899
echo " Re-run with --confirm."

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,9 @@ assert_topology
203203
if [[ "$STAGE" == "B" || "$STAGE" == "C" ]]; then
204204
echo "NOTE: promoting the frozen backup now. Traces the successor accepted after cutover_start ($CUTOVER_START) will" >&2
205205
echo " stop being live; recover them from the parked traces_post_rollback_backup (kept until finalize.sh) if needed." >&2
206+
echo "NOTE: the promote is a single ON CLUSTER RENAME of the live 'traces' — synchronous across the shard's replicas, but" >&2
207+
echo " with a brief sub-second cross-replica read skew as it propagates (a read on a lagging replica sees the" >&2
208+
echo " pre-rollback 'traces'). On a multi-replica cluster run this in a maintenance moment / with reads quiesced, as for the wrap." >&2
206209
fi
207210

208211
case "$STAGE" in

0 commit comments

Comments
 (0)