|
14 | 14 | # the exact 000101 shadow — schema, codecs (000106/000107) and replica path — so the |
15 | 15 | # estate matches the applied Liquibase state and a retry starts from a clean shadow. |
16 | 16 | # Both are `*_backup` names — retained until this script runs; the working `traces_local_v2` shadow is never detected as a |
17 | | -# backup. This detects whichever parked table is present and never touches the live `traces` / `traces_local` shard. It |
18 | | -# refuses if the live `traces` is empty while the backup is not (the live table may be unhealthy and the "backup" the only |
19 | | -# copy), and if BOTH parked names exist (an ambiguous, unexpected state that a human must resolve). |
| 17 | +# backup. This detects whichever parked table is present and never touches the live `traces` / `traces_local` shard. |
| 18 | +# Detection is CLUSTER-WIDE (via clusterAllReplicas, like exchange_and_wrap.sh's settle gate): because finalize is the one |
| 19 | +# irreversible step and production is multi-replica, a name present on only SOME replicas means an ON CLUSTER DDL has not |
| 20 | +# finished propagating, so acting on the connected node's partial view could recycle/drop mid-transition — it refuses |
| 21 | +# loudly instead. It also refuses if the live `traces` is empty while the backup is not (the live table may be unhealthy |
| 22 | +# and the "backup" the only copy), if BOTH parked names exist (an ambiguous state a human must resolve), and — before a |
| 23 | +# recycle — if `traces_local_v2` already exists (recycle renames the backup INTO that name; a stray shadow means a retry |
| 24 | +# cutover started before the rollback was finalized). |
20 | 25 | # |
21 | 26 | # Connection: clickhouse-client env vars (CLICKHOUSE_HOST, CLICKHOUSE_PORT, CLICKHOUSE_USER, CLICKHOUSE_PASSWORD). |
22 | 27 | # |
@@ -45,16 +50,39 @@ ch() { |
45 | 50 | clickhouse-client --database "$DATABASE" --log_comment 'traces_local_v2_cutover:finalize' --query "$1" |
46 | 51 | } |
47 | 52 |
|
48 | | -exists() { |
49 | | - ch "SELECT count() FROM system.tables WHERE database = '$DATABASE' AND name = '$1'" |
| 53 | +# Cluster-wide detection. finalize is the one irreversible step and production is multi-replica, so a table's presence is |
| 54 | +# resolved across ALL replicas (clusterAllReplicas, mirroring exchange_and_wrap.sh's settle gate), not just the connected |
| 55 | +# node. Resolve the cluster and its replica count once; a down replica makes clusterAllReplicas throw — correct here, |
| 56 | +# since finalizing against an estate we cannot fully see would be unsafe. |
| 57 | +CLUSTER="$(ch "SELECT getMacro('cluster')")" |
| 58 | +[[ -n "$CLUSTER" ]] || { echo "ERROR: could not resolve the '{cluster}' macro (getMacro('cluster') was empty)." >&2; exit 1; } |
| 59 | +REPLICAS="$(ch "SELECT count() FROM clusterAllReplicas('$CLUSTER', system.one)")" |
| 60 | + |
| 61 | +# Classify a table across the cluster: sets CLUSTER_HAS=1 if present on ALL replicas, 0 if on none, and refuses loudly on |
| 62 | +# a mixed (present on some) state — an unfinished ON CLUSTER propagation the connected-node view would hide. Call it |
| 63 | +# directly (NOT in "$(...)"), so its refuse-exit stops the whole script rather than only a subshell. |
| 64 | +CLUSTER_HAS=0 |
| 65 | +classify() { |
| 66 | + local n |
| 67 | + n="$(ch "SELECT count() FROM clusterAllReplicas('$CLUSTER', system.tables) WHERE database = '$DATABASE' AND name = '$1'")" |
| 68 | + if [[ "$n" == "0" ]]; then |
| 69 | + CLUSTER_HAS=0 |
| 70 | + elif [[ "$n" == "$REPLICAS" ]]; then |
| 71 | + CLUSTER_HAS=1 |
| 72 | + else |
| 73 | + echo "ERROR: '$1' exists on $n of $REPLICAS replicas — an ON CLUSTER DDL has not finished propagating." >&2 |
| 74 | + echo " Refusing to finalize a mid-transition cluster; let it settle (or fix the unfinished host), then re-run." >&2 |
| 75 | + exit 1 |
| 76 | + fi |
50 | 77 | } |
51 | 78 |
|
52 | | -[[ "$(exists traces)" != "0" ]] || { echo "ERROR: live 'traces' table not found in '$DATABASE'." >&2; exit 1; } |
| 79 | +classify traces |
| 80 | +[[ "$CLUSTER_HAS" == "1" ]] || { echo "ERROR: live 'traces' table not found on all replicas in '$DATABASE'." >&2; exit 1; } |
53 | 81 |
|
54 | 82 | # Detect the parked backup by name: traces_pre_cutover_backup (post-successful-cutover) or traces_post_rollback_backup |
55 | 83 | # (post-rollback). They never co-exist in a clean flow; if both are present the estate is ambiguous — refuse. |
56 | | -HAS_PRECUTOVER="$([[ "$(exists traces_pre_cutover_backup)" != "0" ]] && echo 1 || echo 0)" |
57 | | -HAS_POST_ROLLBACK="$([[ "$(exists traces_post_rollback_backup)" != "0" ]] && echo 1 || echo 0)" |
| 84 | +classify traces_pre_cutover_backup; HAS_PRECUTOVER="$CLUSTER_HAS" |
| 85 | +classify traces_post_rollback_backup; HAS_POST_ROLLBACK="$CLUSTER_HAS" |
58 | 86 |
|
59 | 87 | if [[ "$HAS_PRECUTOVER" == "1" && "$HAS_POST_ROLLBACK" == "1" ]]; then |
60 | 88 | echo "ERROR: both 'traces_pre_cutover_backup' and 'traces_post_rollback_backup' exist — ambiguous state." >&2 |
@@ -94,6 +122,17 @@ if [[ "$BACKUP" == "traces_post_rollback_backup" ]]; then |
94 | 122 | # naming a laggard that then converges via the DDL queue), NOT globally atomic. Both statements touch only the parked |
95 | 123 | # backup / disposable shadow — never the live `traces` — so unlike the rollback promote and the wrap (which rename live |
96 | 124 | # `traces`) the brief cross-replica skew is invisible to readers, and finalize needs no maintenance window. |
| 125 | + # |
| 126 | + # Guard the destination first: recycle renames the backup INTO `traces_local_v2`, and ClickHouse RENAME fails on an |
| 127 | + # existing target. A stray `traces_local_v2` here means a retry cutover started before this rollback was finalized — |
| 128 | + # refuse (cluster-wide) BEFORE truncating, so we fail early with a clear message instead of after the TRUNCATE. |
| 129 | + classify traces_local_v2 |
| 130 | + if [[ "$CLUSTER_HAS" != "0" ]]; then |
| 131 | + echo "ERROR: 'traces_local_v2' already exists — cannot recycle '$BACKUP' into it (RENAME will not overwrite)." >&2 |
| 132 | + echo " This usually means a retry cutover began before the rollback was finalized. Resolve the estate" >&2 |
| 133 | + echo " (inspect/drop 'traces_local_v2') before recycling." >&2 |
| 134 | + exit 1 |
| 135 | + fi |
97 | 136 | if [[ "$CONFIRM" != "1" ]]; then |
98 | 137 | echo "DRY RUN: would recycle $DATABASE.$BACKUP into an empty $DATABASE.traces_local_v2 (TRUNCATE + RENAME)." |
99 | 138 | echo " Re-run with --confirm." |
|
0 commit comments