Skip to content

Commit 14fe01b

Browse files
andrescrzclaude
andauthored
[OPIK-6901] [BE] test: buffered traces cutover deletion-bridge E2E gate + runbook (#7572)
* [OPIK-6901] [BE] test: buffered cutover deletion-bridge E2E gate + runbook * test(cutover): address Baz review — safe wrap default, input validation, sim fixes Make the Distributed wrap opt-in in exchange_and_wrap.sh (default EXCHANGE-only, --with-wrap to combine; --skip-wrap kept as an explicit alias) so a bare run can't apply the wrap and break trace deletes before the DAO retarget. Validate --database as a ClickHouse identifier across all drivers and the timestamp args (--backfill-start/--cutover-start) and verify's table args, so operator inputs interpolated into the reference SQL fail cleanly instead of altering the query. Load-sim fixes: end() the live-traffic update so it flushes as completed traffic; fill only the missing one of --workspace-id/--project-id; exclude in-flight pool ids from delete-traffic refills; correct the seeding-order comment. Runbook + README updated for the new wrap default and the replay-wall-time wording. * test(cutover): validate the resumed backfill_start anchor content On resume, backfill.sh read the operator-owned --state-file and reused its content as backfill_start without checking it. A corrupted or wrong file would feed a garbage anchor forward to step 2. Validate the content is a well-formed timestamp and fail fast otherwise. This addresses the spirit of the --state-file finding by constraining the anchor's value rather than allow-listing its path (the path is operator-owned; allow-listing would break legitimate absolute paths and crosses no privilege boundary the operator lacks). * test(cutover): address second Baz review — wrap-only split-state guard + fixes - exchange_and_wrap.sh --wrap-only now refuses when traces_local_v2 still exists (EXCHANGE done but post-swap RENAME not): wrapping then would orphan the old data under the wrong name. Prints the rename to finish first. - seed_history: build BAD_ID_INSTANT from a fixed date so it can't raise ValueError at import on a Feb-29 run (2201 is not a leap year). - delete_traffic: a transient search_traces failure now backs off and retries instead of being mistaken for "no more traces" and ending the run. - README: document the mutually-exclusive wrap flags and EXCHANGE-only default; reword the awkward "EXCHANGEd" to "already-swapped". * test(cutover): address third Baz review — numeric arg validation + comment fixes - Validate numeric args that flow into the reference SQL / window arithmetic across verify.sh, estimate.sh, delta_replay.sh and backfill.sh (--sample-mod, --max-rows-per-insert, --probe-rows, --max-insert-block-size, week/stride/ pause/factor args) so they fail cleanly instead of altering the query. - Fix the stage-C rollback comment: the reverse-replay runs but matches zero ids here (no deletes bridged in the window), rather than being skipped. - Correct the durationMismatches Javadoc to state the actual 1.5 us tolerance and why (both endpoints truncate ns->us); the threshold is unchanged. * test(cutover): self-heal leaked topology in resetTables @beforeeach reset assumed the prior test left `traces` a regular table. A test that failed mid-cutover (after a swap/wrap, before its rollback) would leave `traces` a Distributed wrapper (or the successor) over dropped/renamed tables, cascading into every later test. Reverse whichever of the two completed non-canonical topologies (post-wrap, post-EXCHANGE) is present before truncating, so one mid-cutover failure no longer corrupts the rest of the run. * test(cutover): make resetTables normalize from any leaked topology The prior self-heal reversed only the two completed topologies and ran DDL without confirming every referenced table existed, so a partial swap/wrap (only the first of a two-statement helper) could make the reset itself throw and cascade. Rewrite it to normalize to canonical from every reachable state — canonical, partial/complete EXCHANGE, partial/complete wrap — using the Nullable-end_time schema check to tell the original table from the successor, and guarding every DDL on the tables it touches so no leaked state can throw. Verified: all 8 tests in the class pass. * test(cutover): address Thiago review — drop-size guard, UTC bounds, log_comment, doc fixes Substantive: - Add SETTINGS max_table_size_to_drop = 0 to the stage-A TRUNCATE and the finalize DROP so rollback/finalize don't throw on prod-scale (>50 GB) tables. - Force 'UTC' on the toDateTime64 window bounds (000001 INSERT + backfill.sh reconciliation) so the window invariant is enforced, not assumed to match the UTC-forced settled/split checks. - Log both counts in the reconciliation "OK" line; reserve "equal" for exact match. - Add log_comment to the heavy backfill/delta INSERTs and the deletion-replay DELETE for query_log/mutations attribution during the cutover. Docs/comments: - Fix the false "id skip index" claim on source traces (000088 indexes only created_at/last_updated_at); reconcile the README. - Note the deferred --wrap-only path briefly makes traces unavailable and needs the buffer re-raised (driver reminder + README). - Reword the Go/No-Go replay item to gate on the final-delta->EXCHANGE gap. - Restate prereq 1 without a never-running retention cycle. - Correct the 000001 "read in destination order" comment (no such guarantee). - Document the workspace-scoped replay residual (retired by OPIK-7483; caught by 000005), stage-B non-atomic recovery, and stage-C brief traces absence. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(cutover): make stage-C rollback gapless; enforce --wrap-only maintenance gate Stage C previously DROP-then-RENAMEd, leaving `traces` briefly absent. Promote the original back with an atomic EXCHANGE instead (traces is never absent on a node), then drop the now data-less wrapper and park the shard — same end state, gaplessly. Updated the gate test's rollbackAfterWrap() to match; 8/8 green. exchange_and_wrap.sh: the deferred --wrap-only path is a non-atomic RENAME->CREATE that briefly makes `traces` unavailable against live, unbuffered ingestion. Turn the documented precondition into an enforced gate — --wrap-only now requires --confirm-maintenance (checked up front, before touching ClickHouse) asserting the buffer is re-raised / ingestion quiesced / a maintenance window is in effect. True per-node gaplessness for the wrap itself (pre-created Distributed + atomic multi-RENAME rotate) is deferred to prod-clone validation (OPIK-7404/7405). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(cutover): make stage-B rollback atomic; clarify buffer prereq is operator-verified Stage B was EXCHANGE + RENAME (two non-atomic statements) — a failed RENAME could strand the successor under traces_pre_cutover_backup. Replace with a single atomic multi-target RENAME rotate (traces -> traces_local_v2, traces_pre_cutover_backup -> traces, the name freed by the first clause), so there is no orphan window. The freed-name-reuse rename-rotate is validated against ClickHouse 26.3 by the gate test (8/8 green); updated rollbackExchangeBack() to match. README prereq 6: state explicitly that the async-insert ceiling is operator-verified (confirmed on the prod-clone/staging load test + Go/No-Go), not script-enforced — it's a backend per-query setting the migration's own clickhouse-client session cannot see or gate on. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(cutover): make the Distributed wrap gapless per node The wrap was RENAME traces->traces_local then CREATE Distributed traces, leaving `traces` absent in between. Replace with the validated rename-rotate: pre-build the Distributed wrapper under a temp name (traces_dist; its traces_local target need not exist yet), then one atomic multi-target RENAME rotates the data to traces_local and the wrapper into traces (the name freed by the first clause). So `traces` transitions MergeTree->Distributed with no absent window on a node. A cross-node ON CLUSTER propagation skew still exists, so --wrap-only keeps requiring --confirm-maintenance (comments/README updated to reflect per-node gapless + the remaining cross-node window). resetTables drops a leaked traces_dist (a test dying between the CREATE and the RENAME). Gate test 8/8 green on CH 26.3. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(cutover): stage-C rollback drops the ex-wrapper by an unambiguous temp name Stage C did EXCHANGE traces<->backup then DROP traces_pre_cutover_backup — a DROP of a name that a data-bearing table had just occupied, which reads as unsafe even though ON CLUSTER DDL ordering makes it correct. Replace with a single atomic 3-way RENAME rotate (traces -> traces_dist_old, traces_pre_cutover_backup -> traces, traces_local -> traces_local_v2), then DROP traces_dist_old — a fresh name only the data-less Distributed wrapper ever held, so the DROP structurally cannot hit the original data regardless of replica timing. Still gapless. resetTables also clears a leaked traces_dist_old; gate test 8/8 green on CH 26.3. Also append --confirm-maintenance to the deferred-wrap guidance echo so the printed command is copy-paste complete. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(cutover): enforce wrap prerequisites, tag DDL, correct buffer-coverage claim - exchange_and_wrap.sh: require --confirm-daos-retargeted whenever the wrap is applied (--with-wrap or --wrap-only) — the OPIK-7455 hard prerequisite, else DELETE FROM the Distributed traces returns 500. Fail fast, before ClickHouse. - --wrap-only now also asserts traces_pre_cutover_backup exists, so it can't wrap into a state where stage C rollback has nothing to restore (one-way cutover). - Tag the exchange/wrap DDL (000003) and all rollback stages (000004 A/B/C + reverse-replay) with log_comment via a leading SET (reliable for DDL), for cutover attribution in system.query_log. - README: correct the "already covered" claim — the same-run --with-wrap path shares the brief cross-node ON CLUSTER window; the buffer reduces but does not eliminate INSERT exposure, and SELECTs are unbuffered. Driver-only guards + reference-SQL tags (not exercised by the gate test's embedded SQL); to be exercised in the post-merge local rehearsal. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(cutover): enforce the async-insert buffer precondition on the EXCHANGE The buffer (asyncInsertBusyTimeoutMaxMs) is the zero-loss mechanism for the forward cutover — it holds writes across the final-delta->EXCHANGE gap so they land on the new table; at the default, a write in that window can commit to the old table and be lost after the swap. It was only a documented/echoed reminder. Add a fail-fast --confirm-buffer-raised gate, required on every EXCHANGE path (default and --with-wrap; not --wrap-only, which does no EXCHANGE), matching the --confirm-maintenance / --confirm-daos-retargeted pattern. It's an operator assertion, not verification (the buffer is a backend per-query setting the migration's clickhouse-client session can't read), so the Go/No-Go load-test item remains the actual "took effect" check. README + prereq #6 + Go/No-Go and the EXCHANGE example updated to match. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(cutover): address Baz review — arg guards, query tags, verify skip-index, rollback restart, replay coverage - Drivers: guard value-taking flags with ${2:?...} so a bare flag prints a clear "requires a value" error instead of a set -u unbound/shift crash; tag every ch() read (and estimate probe, exchange cutover_start, verify compare/drill-down) with --log_comment for system.query_log attribution. - 000005 verify: add use_skip_indexes_if_final=1 to the compare + drill-down FINAL queries so the created_at skip index prunes the scan on the 4 TB table. - README rollback: reverting traceColumnsNonNullable requires a rolling restart (config is a startup snapshot via toInstance); until then the app mixes sentinel and null semantics against the Nullable column. - Test: add workspaceScopedReplaySparesLiveCrossProjectRow — the empty-project replay branch must spare a live cross-project reused id (guard coverage); rename weekInstant's misleading minuteOffset param to secondOffset. 9/9 green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(cutover): address JetoPistola review (clear wins) — align test SQL, fix flakes/footguns - Gate test: replace parseDateTime64BestEffort with toDateTime64(..., N[, 'UTC']) matching the reference SQL exactly (also drops the OPIK-5694 anti-pattern that pollutes system.errors); derive the during-window delta instants from the CH server clock (via a later now64(6)) instead of the JVM host clock, so host<-> container skew can't push last_updated_at/created_at below backfillStart and flake the delta arms. - delete_traffic.py: tolerate a few consecutive empty refills (delete-mask visibility lag) before concluding the project is drained, so a blip doesn't end the run early. - requirements.txt: drop `opik` (README installs it editable; listing it here lets pip shadow the local unreleased SDK) and pin a clickhouse-connect floor. - verify.sh / estimate.sh: capture the clickhouse-client output before `read <<<` so set -e catches an infra failure instead of it being swallowed and mislabeled (verify: a false MISMATCH; estimate: a false "table is empty"). Gate test 9/9 green on CH 26.3. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(cutover): address JetoPistola review (remaining) — replay fail-safe, tiered gate, version-check, gate-scope note - Deletion replay + reverse-replay: guard the deletion-events subqueries with length(deleted_id)=36 (and project_id) so a malformed bridge row is skipped rather than aborting the whole replay on toFixedString — a >36-char id can't match a real trace id, so skipping loses nothing. Mirrored in the gate test's inline replay to keep it aligned with the reference SQL. - backfill.sh: require --confirm-tiered-headroom when the destination storage_policy is tiered (multi-volume) or differs from the source — the whole-node free-space gate can't see per-volume hot headroom (an accurate auto-check isn't feasible), so force an explicit operator acknowledgment. - 000005 + verify.sh: add a version-collapse check — count old-table keys whose last_updated_at has more ns-distinct values than us-distinct, surfacing where the ns->us truncation could pick a different ReplacingMergeTree version than source FINAL (invisible to the us fingerprint). Reported, not pass/fail. - Gate test Javadoc: document that the inline SQL reimplementation is deliberate (an independent logic check); the drivers are the single-source reference-SQL path, validated end-to-end by the prod-clone QA. Kept inline aligned to the ref. Gate test 9/9 green on CH 26.3. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(cutover): cross-reference the gate test from the reference SQL it mirrors Add a one-line pointer to each reference .sql the gate reimplements inline (000001-000004 family) noting that TracesLocalV2CutoverTest mirrors it and the two must be kept in step. Closes the drift-awareness gap in the direction the test's Javadoc doesn't cover — a maintainer editing the SQL now sees the link without opening the test. 000005 is not pointed at (its fingerprint is validated independently, not reimplemented inline). Comment-only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(cutover): address Baz — bad-id partition msg, liveCount FINAL, empty-old-table guard - seed_history.py: the bad-id log said id_at ~2201, but the successor's 32-bit DateTime wraps to ~2065 (OPIK-7456) — point operators at the ~2065 partition. - liveCount / liveCountScoped: SELECT ... FROM %s FINAL so leak/survivor assertions count ReplacingMergeTree winners (mask-applied), aligning with 000005; 9/9 green. - verify.sh: an empty OLD_TABLE now fails if NEW_TABLE has rows (unexplained successor data) instead of exiting 0 "nothing to verify"; both-empty still exits 0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(cutover): final deletion replay before EXCHANGE; version-collapse fails REVIEW-REQUIRED #687 — close the delete coverage hole: delta_replay.sh replays deletes only up to when it runs, but cutover_start is captured later in exchange_and_wrap.sh, so deletes bridged in [final delta_replay, cutover_start) were covered by neither the forward replay nor the rollback reverse-replay and leaked live across the swap. exchange_and_wrap.sh now takes --backfill-start (required for every EXCHANGE path) and, right after capturing cutover_start, runs a final deletion replay — reusing the single-source 000002 deletion-replay block (now marked so it can be extracted without re-running the delta INSERT; writes in the gap are carried by the async buffer). Runbook + a gate-test case added; residual is the inherent in-flight micro-window, documented. #717 — verify.sh no longer only warns on version-collapse: a non-zero count is now a REVIEW-REQUIRED failure (exit 3, distinct from a fidelity mismatch exit 1) unless the operator reviews and passes --allow-version-collapse; --drill-down lists the offending keys (new 000005 version-check-drill block). Gate test 10/10 green on CH 26.3. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(cutover): server-clock resurrection instant + explicit container teardown - deleteThenResurrectSurvivesTheReplay: source the recreated row's last_updated_at from the CH server clock (now64(6) -> Instant) instead of JVM Instant.now(), so host<->container skew can't put it below backfillStart and make the delta miss the resurrection path (same fix already applied to the main test). - Add an @afterall (PER_CLASS, non-static) that stops the ClickHouse + ZooKeeper containers and closes the network, so the dedicated non-reused containers don't rely solely on the Ryuk reaper across reruns / a shared JVM. Gate test 10/10 green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(cutover): gate EXCHANGE/rollback on retention-paused; make delete_traffic best-effort Address Baz round: - exchange_and_wrap.sh / rollback.sh: require --confirm-retention-paused on every EXCHANGE path and on rollback stages B/C. Retention deletes bypass the deletion bridge, so a sweep in the cutover/rollback window leaks live across the swap (forward) or resurrects a deleted row from the backup (reverse-replay). It is an operator assertion — a backend setting the script can't read — so the README Go/No-Go keeps the real "actually paused on every backend" verification. - delete_traffic.py (#851): larger newest-page refill and more generous empty-refill tolerance so mask-visibility lag doesn't end the run early; docstring states it is a best-effort traffic generator, not a guaranteed full-drain. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(cutover): resume only on dst>=src; residual full-compare gate; test hygiene Address Baz round (items 1-5): - backfill.sh (#916, high): resume skips a window only when dst >= src. DIVERGENCE is no longer a resume criterion — a partially-copied window within tolerance would be skipped and its pre-anchor rows lost forever (delta only re-copies at/after backfill_start). The INSERT is idempotent, so re-copying a short window is safe; DIVERGENCE still governs the post-copy abort. - README (#666, high): the pre-EXCHANGE fidelity gate must be a full compare (--sample-mod 1 --weeks-stride 1, no week narrowing); a single cross-project workspace-scoped residual row can be hashed out or week-skipped by sampling. - delete_traffic.py (#890): comment referenced newest 500; now references REFILL_FETCH. - TracesLocalV2CutoverTest (#901): wrapInDistributed() uses a text block + .formatted(). - _common.py (#922): delete the cutover-anchor trace in a finally block so discovery leaves no extra live trace skewing seeded counts. Gate test 10/10. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * 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> * fix(cutover): server-clock resurrection stamp; scope loss claim; confirm anchor cleanup Address Baz round: - TracesLocalV2CutoverTest (#808): the workspace-scoped resurrection row stamped last_updated_at from Instant.now() (JVM clock) while backfillStart uses server now64(6); host/container skew could put it below backfillStart and flake the delta. Now parses nowMicros() like the other two window inserts. Swept the file: this was the last host-clock stamp feeding a server-clock comparison. Gate test 10/10. - README (#792): the opening claimed "zero deletion loss" while the cutover-tail section documents a bounded residual micro-window; reworded to "near-zero deletion loss" with a pointer to that section's mitigation. - _common.py (#789): delete_traces returns before ClickHouse applies the mask, so the #922 cleanup could still race the seeder; poll for the anchor's disappearance (bounded, best-effort) before returning. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(cutover): add --reverse-replay-only restart path; guide partial-EXCHANGE rollback Address Baz round (rollback robustness): - #801: a stage B/C promote and its reverse-replay run as two statements; if the replay is interrupted, the promote has already restored the canonical shape, so re-running the stage is refused by the topology guard — stranding post-cutover deletes unreplayed (they resurrect). This hole is symmetric across B and C. Add --reverse-replay-only: re-applies just 000004_rollback_reverse_replay.sql against the live traces (asserts non-Distributed MergeTree; requires --cutover-start and --confirm-retention-paused). Idempotent, safe to re-run, closes both stages. - #798: if the forward EXCHANGE succeeded but its post-swap RENAME did not, the parked original is still under traces_local_v2 and stage B aborts "backup not found". Stage B now detects that state and prints the one-line RENAME to finish it (same remediation exchange_and_wrap.sh gives), then proceeds. Not auto-completed: rollback does exactly one thing per run. - README: document both recovery paths in the rollback section. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(cutover): drop flaky replay-time gate; disclose workspace-replay residual Address Baz round: - TracesLocalV2CutoverTest (#444): the replay wall-time assertion (< 60s) gated a non-correctness, environment-sensitive property (container startup, CI contention), making it a flaky gate; correctness is already asserted (the mask is applied). Drop the hard bound and the unused REPLAY_BUDGET_SECONDS; keep the measurement as a log. - README (#447): the workspace-scoped replay branch was called a "faithful mirror" that "ends matching the source" with no caveat, but 000002 documents a known residual (the (workspace_id, id) resurrection guard lets a live copy in one project shield the same reused id's delete in another). Disclose it, note 000005 flags it (ok=0), and point at OPIK-7483 which retires the arm. Gate test 10/10. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(cutover): fix grammar in workspace-scoped replay explanation Address Baz #3657021887: "the source delete it replays already removed" -> "the source deletion that it replays already removed". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 7cc0416 commit 14fe01b

24 files changed

Lines changed: 4806 additions & 0 deletions

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

Lines changed: 676 additions & 0 deletions
Large diffs are not rendered by default.

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

Lines changed: 319 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
-- runbook traces-local-v2-cutover — step 1 of 3: backfill (reference statement)
2+
-- The gate test TracesLocalV2CutoverTest reimplements this statement inline; keep the two in step (see its Javadoc).
3+
--
4+
-- This file is the SINGLE source of the backfill INSERT; ../backfill.sh reads it, substitutes the ${...} placeholders
5+
-- (database, window bounds, block size) and runs it once per time sub-window — so the script and this reference never
6+
-- drift. Run the migration through backfill.sh, never this file by hand. WINDOW_LO/WINDOW_HI are a created_at half-open
7+
-- range the driver picks so each INSERT stays under its --max-rows-per-insert bound (see README "Batching and throttling").
8+
--
9+
-- Slicing rationale (created_at, not id / not workspace), delta and replay design: see ../../README.md.
10+
-- Notes on the statement:
11+
-- * The SOURCE is sliced by created_at (immutable across upserts, backed by a minmax skip index). The DESTINATION's
12+
-- id_at partition is derived from each row's id independently of the slice.
13+
-- * end_time and ttft are the two denullified columns: coalesce them to their sentinels (epoch / NaN).
14+
-- * is_deleted is omitted so the new column defaults to 0.
15+
-- * apply_deleted_mask stays at its default 1, so rows already lightweight-deleted on the source are skipped.
16+
-- * No explicit ORDER BY: omitted deliberately to avoid a full per-window sort (memory). A parallel SELECT gives no
17+
-- output-order guarantee, so inserted blocks may span/interleave partitions; the destination ReplacingMergeTree
18+
-- dedups regardless of insert order and background merges compact the parts. This is NOT a claim that rows arrive
19+
-- in sort-key order — do not rely on it (see README "Why slice by created_at").
20+
-- * SETTINGS max_insert_block_size bounds the rows per part-forming block; peak insert memory is a small multiple of
21+
-- the smaller of that and min_insert_block_size_bytes (256 MB default), which dominates for wide trace rows.
22+
23+
INSERT INTO ${ANALYTICS_DB_DATABASE_NAME}.traces_local_v2 (
24+
id,
25+
workspace_id,
26+
project_id,
27+
name,
28+
start_time,
29+
end_time,
30+
input,
31+
output,
32+
metadata,
33+
tags,
34+
created_at,
35+
last_updated_at,
36+
created_by,
37+
last_updated_by,
38+
error_info,
39+
thread_id,
40+
visibility_mode,
41+
truncation_threshold,
42+
input_slim,
43+
output_slim,
44+
ttft,
45+
source,
46+
environment
47+
)
48+
SELECT
49+
id,
50+
workspace_id,
51+
project_id,
52+
name,
53+
start_time,
54+
coalesce(end_time, toDateTime64('1970-01-01 00:00:00', 6)) AS end_time,
55+
input,
56+
output,
57+
metadata,
58+
tags,
59+
created_at,
60+
last_updated_at,
61+
created_by,
62+
last_updated_by,
63+
error_info,
64+
thread_id,
65+
visibility_mode,
66+
truncation_threshold,
67+
input_slim,
68+
output_slim,
69+
coalesce(ttft, toFloat64('nan')) AS ttft,
70+
source,
71+
environment
72+
FROM ${ANALYTICS_DB_DATABASE_NAME}.traces
73+
WHERE created_at >= toDateTime64('${WINDOW_LO}', 9, 'UTC')
74+
AND created_at < toDateTime64('${WINDOW_HI}', 9, 'UTC')
75+
SETTINGS max_insert_block_size = ${MAX_INSERT_BLOCK_SIZE},
76+
log_comment = 'traces_local_v2_backfill:${WINDOW_LO}:${WINDOW_HI}';
77+
78+
-- Per-window reconciliation is automated by backfill.sh (uniqExact of the dedup key, aborting on > 0.01% divergence);
79+
-- fidelity QA across the whole copy is 000005 via verify.sh. Rollback before the EXCHANGE: rollback.sh --stage A.
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
-- runbook traces-local-v2-cutover — step 2 of 3: delta-insert + deletion replay
2+
-- The gate test TracesLocalV2CutoverTest reimplements these statements inline; keep the two in step (see its Javadoc).
3+
-- Run this only after the whole backfill (step 1) is complete and reconciled.
4+
5+
-- Step 0: The SQL below (delta-insert + deletion replay) is the single source driven by ../delta_replay.sh, which reads
6+
-- this file, substitutes the placeholders and runs it — never run this file by hand:
7+
-- ../delta_replay.sh --database opik --backfill-start '2025-06-01 12:00:00.000000'
8+
-- The surrounding config operations (buffer raise/restore) and the go/no-go checkpoint stay with the operator, where
9+
-- situational awareness matters most — those are config/judgement, not SQL. clickhouse-client prints each statement's
10+
-- elapsed time, which is the replay measurement in step 5.
11+
12+
-- Step 1: BACKFILL_START is the timestamp captured BEFORE the backfill began. backfill.sh prints it at startup
13+
-- ("RECORD backfill_start=..."); if you ran the backfill manually, use the now64(6) you captured before the first
14+
-- INSERT. The delta and the replay both key off this single anchor, so writes during the whole backfill window are
15+
-- covered.
16+
17+
-- Step 2: Raise the async-insert buffer ceiling so the buffer can absorb the cutover window. Set
18+
-- databaseAnalytics.asyncInsertBusyTimeoutMaxMs ~= 10000 (env ANALYTICS_DB_ASYNC_INSERT_BUSY_TIMEOUT_MAX_MS) and roll
19+
-- it out (config push + rolling restart, OR a session-level SET on a dedicated cutover connection). Because
20+
-- async_insert_use_adaptive_busy_timeout=1, this only widens the buffer while rows are queued. VERIFY the widening
21+
-- took effect before proceeding — see README.
22+
23+
-- Step 3: Delta-insert — re-copy every row written during the backfill window. Anchored on
24+
-- created_at OR last_updated_at >= backfill_start (NOT last_updated_at alone): last_updated_at is client-supplied on the
25+
-- batch-ingest path, so it is not a reliable "changed since" signal by itself. Every trace write sets EITHER a fresh
26+
-- server created_at (batch-ingest path) OR a fresh server last_updated_at (create/update merge paths), so the union is
27+
-- complete. ReplacingMergeTree dedups the re-copied rows against the backfilled ones (newest last_updated_at wins).
28+
-- Uses ${BACKFILL_START}. SETTINGS max_insert_block_size bounds per-block memory as in step 1.
29+
-- BATCHING: the delta covers only writes during the backfill window, not the whole table, so it is normally one
30+
-- statement. If the backfill ran for days on a busy system and the delta is large, run it as two batched passes to keep
31+
-- each INSERT bounded (both columns have a minmax skip index, so each pass prunes):
32+
-- (a) created_at >= backfill_start -- batch by created_at sub-windows
33+
-- (b) last_updated_at >= backfill_start AND created_at < backfill_start -- the updates-to-old-rows arm; batch by
34+
-- last_updated_at sub-windows. (a) ∪ (b) equals the OR below, with no overlap.
35+
-- >>> BEGIN delta-insert
36+
INSERT INTO ${ANALYTICS_DB_DATABASE_NAME}.traces_local_v2 (
37+
id,
38+
workspace_id,
39+
project_id,
40+
name,
41+
start_time,
42+
end_time,
43+
input,
44+
output,
45+
metadata,
46+
tags,
47+
created_at,
48+
last_updated_at,
49+
created_by,
50+
last_updated_by,
51+
error_info,
52+
thread_id,
53+
visibility_mode,
54+
truncation_threshold,
55+
input_slim,
56+
output_slim,
57+
ttft,
58+
source,
59+
environment
60+
)
61+
SELECT
62+
id,
63+
workspace_id,
64+
project_id,
65+
name,
66+
start_time,
67+
coalesce(end_time, toDateTime64('1970-01-01 00:00:00', 6)) AS end_time,
68+
input,
69+
output,
70+
metadata,
71+
tags,
72+
created_at,
73+
last_updated_at,
74+
created_by,
75+
last_updated_by,
76+
error_info,
77+
thread_id,
78+
visibility_mode,
79+
truncation_threshold,
80+
input_slim,
81+
output_slim,
82+
coalesce(ttft, toFloat64('nan')) AS ttft,
83+
source,
84+
environment
85+
FROM ${ANALYTICS_DB_DATABASE_NAME}.traces
86+
WHERE created_at >= toDateTime64('${BACKFILL_START}', 6)
87+
OR last_updated_at >= toDateTime64('${BACKFILL_START}', 6)
88+
SETTINGS max_insert_block_size = ${MAX_INSERT_BLOCK_SIZE},
89+
log_comment = 'traces_local_v2_cutover:delta_insert';
90+
-- >>> END delta-insert
91+
92+
-- Step 4: Deletion replay — remove from the destination every row that was deleted on the source since backfill_start
93+
-- AND is still deleted there. Two branches, mirroring the product's two delete paths (TraceService.delete): a delete
94+
-- resolves each trace's owning project and deletes per project; ids it cannot resolve fall back to a workspace-scoped
95+
-- delete (TraceDAO DELETE_BY_ID with no project filter). The bridge records the first with the project and the second
96+
-- with an EMPTY project_id (DeletionEventDAO: "project_id is empty for workspace-scoped source tables"). So:
97+
-- * events WITH a project -> match the FULL key (workspace_id, project_id, id). Exact, prunes on the destination
98+
-- primary key, and correct even when an id is reused across projects (ids are not globally unique).
99+
-- * events WITHOUT a project -> match (workspace_id, id). A faithful mirror of the source's workspace-scoped delete.
100+
-- RESURRECTION GUARD (the `NOT IN traces` arm): a trace can be deleted and then re-created/updated under the same id
101+
-- during the window (client-supplied ids; the delete is a mask, a newer insert wins under FINAL). Such an id is bridged
102+
-- as deleted but is LIVE again on the source, and the backfill/delta already copied its live version. Deleting it by key
103+
-- would drop a row that is live on the source — silent data loss. So each branch deletes only ids that are NOT currently
104+
-- live on the source (mask-honored). The `id IN (deleted_ids since anchor)` bound keeps the deleted-id set tiny
105+
-- (retention is off, so these are user-scale deletes); `traces` has no id skip index (000088 indexes only
106+
-- created_at/last_updated_at — id minmax/bloom indexes exist only on traces_local_v2), so this source lookup is a
107+
-- bounded id-filtered read of that tiny set, not a value-indexed prune of the ~4 TB table.
108+
-- KNOWN RESIDUAL (workspace-scoped arm only): its guard keys on (workspace_id, id), so an id live in ONE project shields
109+
-- the deletion of that id's now-deleted copies in OTHER projects (ids are not globally unique). Requires cross-project id
110+
-- reuse + a workspace-scoped delete + a resurrection in the window — rare. The 000005 FINAL fingerprint flags it as an
111+
-- extra destination row (ok=0) rather than passing silently. OPIK-7483 removes the workspace-scoped delete path at the
112+
-- source (deletes always carry project_id), retiring this arm and the residual.
113+
-- allow_nondeterministic_mutations: a lightweight DELETE with cross-table subqueries is flagged nondeterministic, but
114+
-- deletion_events_local and traces are replicated and identical on every node and the window predicate is fixed, so the
115+
-- subqueries resolve to the same set on every replica. Idempotent (never masks a live-on-source id, so re-runs converge).
116+
-- lightweight_deletes_sync = 2: block until the delete mutation has completed on EVERY replica, not just the one that
117+
-- accepted it. The mutation is otherwise asynchronous, so without this the verify step (and the EXCHANGE) could run
118+
-- against a replica where the mask is not yet applied — a false mismatch, or worse an incomplete cutover.
119+
-- Uses ${BACKFILL_START}. Retention is disabled everywhere (see step 6), so this is user-scale volume — a single
120+
-- mutation. If it is ever large (e.g. retention enabled), bound each mutation by a partition predicate and loop the
121+
-- weeks, e.g. AND toMonday(id_at) = toDate('<week>').
122+
-- length(...) = 36 guards: toFixedString(x, 36) THROWS on a value longer than 36 bytes, which would abort the whole
123+
-- replay on a single malformed bridge row. For source_table='traces' the ids are 36-char UUIDs, so this is latent — but
124+
-- a malformed (non-36-char) deleted_id/project_id can't match a real trace id anyway, so skipping it via the length
125+
-- guard loses nothing and turns a hard abort mid-cutover into a benign no-op. Same guards in the reverse-replay.
126+
-- >>> BEGIN deletion-replay
127+
DELETE FROM ${ANALYTICS_DB_DATABASE_NAME}.traces_local_v2
128+
WHERE (
129+
(workspace_id, project_id, id) IN (
130+
SELECT
131+
workspace_id,
132+
toFixedString(project_id, 36),
133+
toFixedString(deleted_id, 36)
134+
FROM ${ANALYTICS_DB_DATABASE_NAME}.deletion_events_local
135+
WHERE source_table = 'traces'
136+
AND event_time >= toDateTime64('${BACKFILL_START}', 6)
137+
AND project_id != ''
138+
AND length(project_id) = 36
139+
AND length(deleted_id) = 36
140+
)
141+
AND (workspace_id, project_id, id) NOT IN (
142+
SELECT
143+
workspace_id,
144+
project_id,
145+
id
146+
FROM ${ANALYTICS_DB_DATABASE_NAME}.traces
147+
WHERE id IN (
148+
SELECT toFixedString(deleted_id, 36)
149+
FROM ${ANALYTICS_DB_DATABASE_NAME}.deletion_events_local
150+
WHERE source_table = 'traces'
151+
AND event_time >= toDateTime64('${BACKFILL_START}', 6)
152+
AND length(deleted_id) = 36
153+
)
154+
)
155+
)
156+
OR (
157+
(workspace_id, id) IN (
158+
SELECT
159+
workspace_id,
160+
toFixedString(deleted_id, 36)
161+
FROM ${ANALYTICS_DB_DATABASE_NAME}.deletion_events_local
162+
WHERE source_table = 'traces'
163+
AND event_time >= toDateTime64('${BACKFILL_START}', 6)
164+
AND project_id = ''
165+
AND length(deleted_id) = 36
166+
)
167+
AND (workspace_id, id) NOT IN (
168+
SELECT
169+
workspace_id,
170+
id
171+
FROM ${ANALYTICS_DB_DATABASE_NAME}.traces
172+
WHERE id IN (
173+
SELECT toFixedString(deleted_id, 36)
174+
FROM ${ANALYTICS_DB_DATABASE_NAME}.deletion_events_local
175+
WHERE source_table = 'traces'
176+
AND event_time >= toDateTime64('${BACKFILL_START}', 6)
177+
AND length(deleted_id) = 36
178+
)
179+
)
180+
)
181+
SETTINGS allow_nondeterministic_mutations = 1,
182+
lightweight_deletes_sync = 2,
183+
log_comment = 'traces_local_v2_cutover:deletion_replay';
184+
-- >>> END deletion-replay
185+
186+
-- Step 5: Measure the replay. Compare its wall time against the buffer window (must fit with margin — acceptance
187+
-- criterion). Re-run steps 3-4 if new rows/deletes accumulated during the replay itself; convergence is fast because
188+
-- the buffer is holding new writes.
189+
190+
-- Step 6 (retention — see README): Data Retention is disabled in every deployment (RETENTION_ENABLED=false), so the
191+
-- retention delete path does not fire during the cutover. The only deletes in this window are user-initiated, and those
192+
-- ARE captured by the bridge. If retention is ever enabled, pause it for the window (or land retention-path capture).
193+
194+
-- rollback: none for the delta-insert (it only adds newest versions that ReplacingMergeTree dedups); the replay is
195+
-- idempotent. If aborting the cutover here, TRUNCATE traces_local_v2 (step 1 rollback) and restore the buffer
196+
-- ceiling (step 2, reverse). The live `traces` table is still untouched until the EXCHANGE in step 3.
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
-- runbook traces-local-v2-cutover — step 3 of 3: EXCHANGE + Distributed wrap (reference statements)
2+
-- The gate test TracesLocalV2CutoverTest reimplements these statements inline; keep the two in step (see its Javadoc).
3+
--
4+
-- ../exchange_and_wrap.sh drives this: it records cutover_start, runs the `exchange` block, and (unless --skip-wrap)
5+
-- the `wrap` block. Run it right after step 2's delta + replay, while the async-insert buffer is still holding writes.
6+
-- Do NOT run this whole file wholesale — the driver runs one marked block at a time. Buffer knob: raise
7+
-- databaseAnalytics.asyncInsertBusyTimeoutMaxMs before the cutover and unset it after (a backend-config action, not SQL).
8+
--
9+
-- cutover_start is a now64(6) captured RIGHT BEFORE the EXCHANGE; a rollback after this point replays deletes that fired
10+
-- on the new live table since then. exchange_and_wrap.sh captures and prints it; record it for the rollback.
11+
12+
-- >>> BEGIN exchange
13+
-- The atomic swap: `traces` now refers to the partitioned data. The displaced old data lands under `traces_local_v2`
14+
-- momentarily, then is renamed to `traces_pre_cutover_backup` so its name marks it as the retained pre-cutover backup,
15+
-- not the "v2" successor (rationale: README "Naming and the parked backup"). Requires an Atomic database (default). If
16+
-- the Liquibase ClickHouse extension cannot execute EXCHANGE ON CLUSTER in the downtime-based path, use the fallback
17+
-- RENAME sequence in the README instead.
18+
-- log_comment tags these DDL statements in system.query_log for cutover attribution (DDL takes it via a leading SET,
19+
-- not a trailing SETTINGS clause).
20+
SET log_comment = 'traces_local_v2_cutover:exchange';
21+
EXCHANGE TABLES ${ANALYTICS_DB_DATABASE_NAME}.traces AND ${ANALYTICS_DB_DATABASE_NAME}.traces_local_v2 ON CLUSTER '{cluster}';
22+
23+
RENAME TABLE ${ANALYTICS_DB_DATABASE_NAME}.traces_local_v2 TO ${ANALYTICS_DB_DATABASE_NAME}.traces_pre_cutover_backup ON CLUSTER '{cluster}';
24+
-- >>> END exchange
25+
26+
-- >>> BEGIN wrap
27+
-- Sharding-ready wrap: move the partitioned table under *_local and front it with a Distributed table keyed on
28+
-- sipHash64(project_id). Transparent on a single shard; switching on sharding later is config-only. The {cluster} macro
29+
-- (not the literal 'cluster') keeps the DDL portable; it is resolved server-side.
30+
-- HARD PREREQUISITE: a Distributed table supports SELECT and INSERT but NOT mutations — a lightweight DELETE returns
31+
-- "DELETE query is not supported" (code 36) and ALTER ... DELETE returns "Distributed doesn't support mutations"
32+
-- (code 48). So the product's delete-by-id AND retention deletes both break the moment this wrap is applied. Do NOT run
33+
-- the wrap until those DAO paths target `traces_local` (see README "The Distributed wrap"). The EXCHANGE above is the
34+
-- data cutover and leaves `traces` a MergeTree where deletes still work; the wrap is a separate, gated step.
35+
--
36+
-- GAPLESS per node: build the Distributed wrapper under a temp name FIRST (its 'traces_local' target need not exist
37+
-- yet — Distributed resolves it lazily), then a SINGLE atomic multi-target RENAME rotates the data to `traces_local`
38+
-- and the wrapper into `traces` (the name freed by the first clause). So `traces` transitions MergeTree->Distributed
39+
-- with no window where the name is absent — unlike a RENAME-then-CREATE, which leaves `traces` missing in between.
40+
-- (A cross-node ON CLUSTER propagation skew still exists, as for any ON CLUSTER DDL; the driver's --confirm-maintenance
41+
-- gate covers it.) Partial-failure recovery: if the RENAME fails after the CREATE, `traces` is untouched (still the
42+
-- successor MergeTree, live) and only the temp wrapper lingers — drop it and retry:
43+
-- DROP TABLE IF EXISTS ${ANALYTICS_DB_DATABASE_NAME}.traces_dist ON CLUSTER '{cluster}' SYNC;
44+
SET log_comment = 'traces_local_v2_cutover:wrap';
45+
CREATE TABLE ${ANALYTICS_DB_DATABASE_NAME}.traces_dist ON CLUSTER '{cluster}' AS ${ANALYTICS_DB_DATABASE_NAME}.traces
46+
ENGINE = Distributed('{cluster}', '${ANALYTICS_DB_DATABASE_NAME}', 'traces_local', sipHash64(project_id));
47+
48+
RENAME TABLE
49+
${ANALYTICS_DB_DATABASE_NAME}.traces TO ${ANALYTICS_DB_DATABASE_NAME}.traces_local,
50+
${ANALYTICS_DB_DATABASE_NAME}.traces_dist TO ${ANALYTICS_DB_DATABASE_NAME}.traces
51+
ON CLUSTER '{cluster}';
52+
-- >>> END wrap
53+
54+
-- After the wrap: restore the buffer ceiling (unset asyncInsertBusyTimeoutMaxMs), verify (README "Verifying the
55+
-- migration"), and keep `traces_pre_cutover_backup` (the parked old data) until the soak completes. Rollback: the
56+
-- 000004_rollback_* files via ../rollback.sh.

0 commit comments

Comments
 (0)