发布

  • [OPIK-6901] [BE] fix: harden traces cutover rollback tooling and runbook (#7645)

    frostbyte_neo 发布于 2026-07-29 09:48:56 +00:00

    • [OPIK-6901] [BE] fix: harden traces cutover rollback tooling and runbook

    Follow-up to the merged buffered-cutover runbook (OPIK-6901), from rehearsing the whole
    procedure — forward and every rollback stage — end-to-end on a local Opik and re-reviewing
    the recovery machinery. Driver logic was sound; this fixes tooling/runbook gaps, one
    recovery-path data-loss footgun, and a post-rollback Liquibase drift.

    Rollback safety:

    • Park the rolled-back successor as traces_post_rollback_backup (symmetric with
      traces_pre_cutover_backup) instead of reusing traces_local_v2. traces_local_v2 had
      double-duty as both the disposable pre-cutover shadow and the retained rollback backup —
      topologically identical states — so a rolled-back estate was ambiguous and --stage A
      (discard the shadow) could TRUNCATE the retained backup. The distinct _backup name makes
      the estate self-describing and closes that footgun. Clean taxonomy: *_backup = retained
      (finalize-only); traces_local_v2/traces_local = working/live successor.
    • Gate --reverse-replay-only on the post-promote shape (traces = restored original with
      traces_post_rollback_backup present), not just "non-Distributed". The guard-less reverse
      replay pointed at the live successor (post-EXCHANGE, pre-rollback) would mask
      deleted-then-recreated rows — silent data loss.
    • Correct the stage-A closing message (shadow discarded, nothing to finalize) — the shared
      "parked backup / finalize" message applied only to stages B/C.

    Finalize recycles the rollback backup into an empty traces_local_v2:

    • A rollback + finalize used to DROP the parked successor, leaving no traces_local_v2 — but
      migration 000101 (ALTERed by 000106/000107) created it, so Liquibase records it as present.
      That drift blocked a retry (the runbook's "traces_local_v2 exists empty" prereq) and left
      the DB inconsistent with the applied changesets.
    • The parked traces_post_rollback_backup IS the 000101 object, renamed (a ReplicatedMergeTree
      replica path is fixed at CREATE and survives renames). So finalize, in the rollback case, now
      recycles it back into an empty traces_local_v2 (TRUNCATE + RENAME) instead of DROP: discards
      the successor data but restores the exact 000101 shadow — schema, codecs and replica path —
      so the estate matches the applied Liquibase state and a retry starts from a clean shadow.
      TRUNCATE-then-RENAME (each atomic, ON CLUSTER; no single truncate-and-rename exists) so a
      crash between them only ever leaves an empty backup that re-running finalize recovers. The
      successful-cutover case still DROPs traces_pre_cutover_backup (unchanged). Retention is
      unchanged: the backup is kept through the soak; the recycle only runs on finalize.sh --confirm.

    Tooling / operability:

    • Decouple clickhouse-client from any bundled ClickHouse container: it is an operator
      prerequisite (native, or the official-image wrapper scripts/clickhouse-client-docker.sh)
      that connects over the network via CLICKHOUSE_* env — the same mechanism in every
      environment. The local-rehearsal README used a compose-container docker exec that neither
      reached the driver scripts nor represents a real run.
    • exchange_and_wrap.sh deferred-wrap hint and the runbook now show --confirm-daos-retargeted
      (required for any wrap); the load-test EXCHANGE example shows the required gates.

    Docs:

    • Add a local "Rehearsing rollback" section; fix the stale 000005 version-check comment
      (last_updated_at is DateTime64(6) since migration 000019; the guard remains a backstop for
      legacy pre-000019 parts).

    Tests:

    • Gate test updated (rollback reimplementation, post-rollback assertions, reset recovery), and
      the stage-B test now re-runs the reverse replay after the rollback to pin its idempotence —
      the contract --reverse-replay-only rests on (post-cutover delete stays masked, no live
      survivor dropped).

    Validated 10/10 on the gate test and end-to-end on local docker: the full forward procedure
    plus every rollback stage (A discard-shadow, B swap-back, C promote-original), each followed by
    finalize — the rollback case recycling to an empty traces_local_v2 whose SHOW CREATE is
    byte-identical to the pristine 000101 table (incl. the replica path), a fresh
    backfill+delta+verify then passing against it (retry-ready); the successful-cutover DROP path
    still finalizing to ['traces']; and --reverse-replay-only allowed+idempotent post-rollback and
    correctly rejected otherwise.

    • docs(cutover): clarify per-host vs cross-host ON CLUSTER atomicity in rollback/finalize

    Baz flagged the stage-B rollback comment as claiming RENAME ... ON CLUSTER is globally
    atomic. It doesn't (the "one atomic statement" point is per-host, contrasting the
    two-statement EXCHANGE+RENAME window), but the wording read as an absolute cross-cluster
    guarantee while the runbook is careful to say "gapless per node". Align the stage-B/C
    rollback SQL comments and the finalize.sh recycle comment with the runbook: state the
    per-host atomicity as the real guarantee, and name the cross-host behavior explicitly —
    ON CLUSTER is eventually-consistent (DDL queue converges a lagging host, or the call fails
    loudly), not globally atomic; the same accepted skew as any ON CLUSTER DDL and a non-issue
    in the default single-node deployment. Comment-only; no behavior change.

    • 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.

    • fix(cutover): cluster-aware finalize detection + assert parked-backup contents in rollback tests

    Addresses two later Baz review comments on PR #7645.

    finalize.sh (severity high) — production is multi-replica, and finalize is the one
    irreversible step, so resolve the parked-backup detection CLUSTER-WIDE via clusterAllReplicas
    (mirroring exchange_and_wrap.sh's settle gate) instead of the connected node only: a name
    present on some-but-not-all replicas means an ON CLUSTER DDL has not finished propagating, so
    acting on one node's partial view could recycle/drop mid-transition — refuse loudly instead of
    a silent "Nothing to finalize". Also guard the recycle destination: refuse (cluster-wide) if
    traces_local_v2 already exists before the TRUNCATE, since recycle renames the backup INTO
    that name and RENAME will not overwrite — a stray shadow means a retry cutover started before
    the rollback was finalized. Declined the broader schema/replica-path validation Baz suggested:
    traces_post_rollback_backup is created only by the rollback RENAME (it IS the 000101 object
    by construction), so validating engine/columns guards a manufactured state the code-style rule
    says to avoid.

    TracesLocalV2CutoverTest (severity low) — the stage-B/C rollback tests asserted the parked
    backup only EXISTS, so a rename that produced an empty or wrong table would pass while losing
    the successor data the parking exists to preserve. Assert the backup actually holds the
    successor data (survivors present, post-cutover delete stays masked) and carries the successor
    (non-Nullable end_time) schema. 10/10 green.

    • docs(cutover): clarify verify.sh --from-week/--to-week are 0-based week offsets

    Full forward E2E rehearsal finding: the runbook's post-EXCHANGE compare hint wrote
    --to-week <last-full-week>, which reads like a date, but verify.sh takes a 0-based
    integer week offset from the anchor Monday. Passing a date fails with "must be a
    non-negative integer". Clarify both mentions (the sealed-weeks compare tip and the
    feasibility bullet) so operators pass an integer offset (e.g. --to-week 3).

    Doc-only; found while validating the runbook against a live run.

    • fix(cutover): make finalize.sh empty-row guard cluster-aware

    Follow-up to the cluster-aware detection: the LIVE_ROWS/BACKUP_ROWS emptiness guard still
    counted on the connected replica only, so the "refuse if live is empty but the backup holds
    data" check reflected one node, not the cluster — inconsistent with the clusterAllReplicas
    detection in the same script (Baz review). Add a max_rows() helper that takes the max row
    count across replicas (clusterAllReplicas grouped by host; fail-loud on a down replica, like
    classify) and use it for both counts.

    Behavior is unchanged on a single replica (max over one host = that host's count); on
    multi-replica it now reflects the most-caught-up replica, so a lagging/empty connected node
    can no longer skew the guard. The post-cutover DROP path's Distributed traces already
    aggregates the cluster and the max-over-hosts still yields its true total. Guard still fails
    safe either way. Validated live: max_rows returns the correct counts against the single-node
    estate; bash -n clean.

    • docs(cutover): correct verify version-check + finalize crash-recovery comments

    Two comment accuracy fixes from a colleague review (JetoPistola).

    000005_verify_migration.sql — the version-collapse guard comment claimed it detects legacy
    sub-microsecond version-selection on pre-000019 parts. It cannot: ClickHouse coerces
    last_updated_at to its DECLARED DateTime64(6) at read time, so raw ns digits are unrecoverable
    through the column and uniqExact(last_updated_at) == uniqExact(toDateTime64(last_updated_at,6))
    always — collapse_keys is structurally 0 on a (6)-declared schema (empirically confirmed;
    inert since #7572). Reword to say it is inert defense-in-depth that can only fire if the
    column is ever re-declared at higher precision, and note that dropping the guard +
    --allow-version-collapse as dead code is a reasonable follow-up (kept out of scope here).

    finalize.sh — the recycle comment said re-running finalize "recovers" a crash between the
    ordered TRUNCATE and RENAME. That's only true single-host; under partial ON CLUSTER
    propagation the re-run's cluster-wide classify sees a mixed state and refuses loudly (finish
    the RENAME by hand), never silently corrupting. Reword to reflect both.

    Comment-only; no behavior change.

    • refactor(cutover): remove the inert version-collapse guard from verify.sh

    The version-collapse guard (version-check + version-check-drill blocks in 000005, the
    --allow-version-collapse flag, and the exit-3 REVIEW-REQUIRED path in verify.sh) is dead
    code: it reads last_updated_at through a DateTime64(6)-declared column, and ClickHouse
    coerces to the declared type at read time, so uniqExact(last_updated_at) always equals
    uniqExact(toDateTime64(last_updated_at, 6)) and collapse_keys is structurally 0 on the
    post-000019 schema — the exit-3 path is unreachable. It has been inert since #7572.

    The team is accepting the (vanishingly small) risk of sub-microsecond version-selection
    collision, and the product is standardizing on microsecond dates, so the guard has no future
    either. Remove it rather than keep dead code with a misleading comment. The microsecond
    fidelity fingerprint (count + normalized checksum, FINAL + mask-honored) is unchanged and
    remains the verify gate; verify.sh now exits 0 (PASS) / 1 (fidelity mismatch) only.

    Validated live: backfill traces -> traces_local_v2 then verify PASSES all windows (exit 0)
    with the simplified final line; the mismatch path still FAILS (exit 1); no version-collapse
    output remains. bash -n clean; gate test unaffected (it does not reference the guard).


    下载附件