Skip to content

[OPIK-7772] [BE] test: guard traces DDL across pre/post-cutover topologies in CI - #7951

Merged
thiagohora merged 4 commits into
mainfrom
thiagoh/OPIK-7772-traces-ddl-topology-guard
Aug 24, 2026
Merged

thiagohora merged 4 commits into
mainfrom
thiagoh/OPIK-7772-traces-ddl-topology-guard

Conversation

@thiagohora

@thiagohora thiagohora commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Details

Stack 1/4 for OPIK-7772 — base main. The traces cutover is produced by the operator runbook, not by Liquibase, so from the moment an install cuts over the changelog and the runtime topology disagree, and the fleet stays mixed for months. A traces schema change must therefore be correct against two physical layouts, and both ways of getting it wrong are silent — they raise nothing at migration time. This adds the CI guard that turns them into merge-blocking failures.

  • The two silent failures: post-cutover a shard-only ADD COLUMN applies without error but is unreadable through the Distributed wrapper (code 47); and a migration that alters traces but forgets the shadow also passes, because the shadow is empty and nothing reads it until the cutover copies into it.
  • TracesSchemaParityPreCutoverTest applies the real changelog as a fresh install does, then asserts three-way parity: traces ≅ the traces_local_v2 shadow ≅ the shipped cutover backfill's INSERT column list.
  • TracesSchemaParityPostCutoverTest stops the changelog after the shadow-table migration (000114), splices in the runbook's EXCHANGE + Distributed wrap, then resumes — so every later migration, including whichever one a PR is adding, runs on the live post-cutover topology.
  • The backfill column list is read from the reference SQL, not restated, so it cannot drift from the tables. It turns out to be exactly traces's insertable columns, and the shadow's are those plus is_deleted, so all three legs are exact assertions rather than fuzzy allowances.
  • Six negative tests inject the drift a careless migration produces (a column or skip index on one table alone, a preserved column missing from the backfill list, a shard-only and a wrapper-only column), so no parity leg can quietly stop firing. The shard-only case also pins the unreadability itself and its counterpart pins the remedy, giving the "read-facing changes go to both" rule an executable demonstration.
  • Parity deliberately compares only the aspects a schema change moves (column sets, insertable columns, skip indices, projections, sorting/primary keys) and enumerates the shadow's intended extras. The baseline type/codec/partition differences stay owned by TracesLocalV2TableTest, TracesLocalV2BenchmarkTest and TracesLocalV2PartitioningTest — comparing them here would fail on every run.
  • No shipped migration is edited (append-only). Dedicated non-reused containers, because the post-cutover splice destructively swaps traces and the negative tests drift it further.

Note: the spike branch andrescrz/NA-cutover-liquibase-reconciliation-spike no longer exists on origin, so SCHEMA_DRIFT_SPIKE_FINDINGS.md and the two spike tests were not retrievable. Both proven facts are re-derived from scratch inside these gates rather than ported — they are now pinned by CI instead of recorded in a document.

Change checklist

  • User facing
  • Documentation update

Issues

  • OPIK-7772

AI-WATERMARK

AI-WATERMARK: yes

  • Tools: Claude Code
  • Model(s): Claude Opus 5 (1M context)
  • Scope: Test-only. Authored both gate classes, the TableSchema system-table snapshot helper, the shared TracesSchemaParity invariant, and the partial-apply / unrun-changeset helpers added to MigrationUtils. The parity model was derived from the real schemas by dumping system.columns / system.data_skipping_indices / system.tables from a migrated container first, not assumed. The spliced EXCHANGE + wrap statements mirror the shipped 000003_exchange_and_wrap.sql and the already-validated inline statements in TracesLocalV2CutoverTest.
  • Human verification: Ticket scope, PR sequencing and the review boundary were directed by the author. All tests were executed locally (see Testing) and every negative test was confirmed to fail on injected drift before being committed green. The author has not line-reviewed every Javadoc paragraph.

Testing

Environment: local, Docker 29.7.2 (linux/aarch64), Corretto 25.0.3, Maven 3.9.9, from apps/opik-backend.

cd apps/opik-backend
mvn test -Dtest=TracesSchemaParityPreCutoverTest
mvn test -Dtest=TracesSchemaParityPostCutoverTest
mvn test -Dtest=ChangelogRebaselineTest   # regression: shares MigrationUtils

Results: pre-cutover 6/6, post-cutover 7/7 at the time of this commit, ChangelogRebaselineTest 2/2 unchanged. Roughly 30s per gate.

Scenarios validated:

  • Happy path, both topologies — full changelog applies cleanly pre-cutover; and post-cutover with nothing left unrun after resuming across the splice, confirming every migration after 000114 tolerates the wrapped layout.
  • Three-way pre-cutover parity — columns, insertable columns vs the backfill list, skip indices (names and definitions), projections, sorting/primary keys.
  • Post-cutover wrapper parity — column list, per-column type and default kind, plus a SELECT of every shard column through the wrapper to prove the measured parity is the parity that matters.
  • Negative controls (6) — each injects drift, asserts the guard rejects it with the offending name in the message, then removes it and re-asserts parity is green.
  • Positive control — altering both shard and wrapper makes the column readable, pinning the documented remedy.

Not run: the full backend suite (unchanged by this PR; CI runs it). No video — non-visual, test-only change.

Documentation

None in this PR. The playbook these gates enforce is documented in stack 4/4, which also adds a pointer from apps/opik-backend/AGENTS.md.

…ogies in CI

The cutover to the partitioned, sharding-ready trace table is produced by the
operator runbook, not by Liquibase, so the changelog and the runtime topology
diverge the moment an install cuts over — and stay diverged for as long as the
fleet is mixed. A `traces` schema change must therefore be correct against two
physical layouts, and both failure modes are silent: post-cutover a shard-only
ADD COLUMN applies without error but is unreadable through the Distributed
wrapper, and a migration that alters `traces` but forgets the shadow leaves the
next cutover copying a table that no longer matches.

Adds the CI guard that turns both into merge-blocking failures:

* TracesSchemaParityPreCutoverTest applies the changelog the way a fresh install
  does and asserts three-way parity — `traces`, the `traces_local_v2` shadow,
  and the shipped cutover backfill's INSERT column list, which is read from the
  reference SQL rather than restated so it cannot drift.
* TracesSchemaParityPostCutoverTest stops the changelog after the shadow-table
  migration (000114), splices in the runbook's EXCHANGE + Distributed wrap, and
  resumes, so every later migration runs against the live post-cutover layout.
  It asserts the changelog applies with nothing left unrun, and that the wrapper
  exposes exactly the shard's columns.
* Six negative tests inject the drift a careless migration would produce — a
  column or skip index on one table alone, a preserved column missing from the
  backfill list, a shard-only and a wrapper-only column — so no parity leg can
  silently stop firing. The shard-only case also pins the unreadability itself,
  and its counterpart pins the remedy, giving the "read-facing changes go to
  both" rule an executable demonstration.

Parity compares the aspects a schema change moves (column sets, insertable
columns, skip indices, projections, sorting/primary keys) and enumerates the
shadow's deliberate extras; the baseline type/codec/partition differences stay
owned by TracesLocalV2TableTest, TracesLocalV2BenchmarkTest and
TracesLocalV2PartitioningTest.

No shipped migration is edited.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@thiagohora
thiagohora requested a review from a team as a code owner August 21, 2026 15:11
@github-actions github-actions Bot added java Pull requests that update Java code Backend tests Including test files, or tests related like configuration. 🔴 size/XL labels Aug 21, 2026
@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

⏱️ pre-commit per-hook timing

Hook Description Result Duration
🌐 typecheck — frontend Whole-project tsc type check 31.82s
☕ spotless — java backend Format Java code 5.60s
🌐 eslint — frontend Lint + autofix JS/TS 4.78s
Total (3 ran) 42.20s
⏭️ 40 skipped (no matching files changed)
Hook Description Result
🐍 trim trailing whitespace — python sdk Strip trailing whitespace ⏭️
🐍 fix end of files — python sdk Ensure files end in a newline ⏭️
🐍 ruff — python sdk Lint + autofix Python (ruff) ⏭️
🐍 ruff-format — python sdk Format Python code (ruff) ⏭️
🐍 mypy — python sdk Static type check ⏭️
🤖 trim trailing whitespace — optimizer Strip trailing whitespace ⏭️
🤖 fix end of files — optimizer Ensure files end in a newline ⏭️
🤖 check yaml — optimizer Validate YAML syntax ⏭️
🤖 check json — optimizer Validate JSON syntax ⏭️
🤖 check toml — optimizer Validate TOML syntax ⏭️
🤖 check for added large files — optimizer Block large files (>1MB) ⏭️
🔐 detect private key — optimizer Block committed private keys ⏭️
🤖 check for merge conflicts — optimizer Block merge-conflict markers ⏭️
🤖 check for case conflicts — optimizer Block case-only name clashes ⏭️
🤖 pyupgrade — optimizer Modernize Python syntax ⏭️
🤖 ruff — optimizer Lint + autofix Python (ruff) ⏭️
🤖 ruff-format — optimizer Format Python code (ruff) ⏭️
🤖 mypy — optimizer Static type check ⏭️
📓 nbstripout — optimizer notebooks Strip notebook output ⏭️
📝 markdownlint — optimizer Lint Markdown ⏭️
🔤 codespell — optimizer Fix common misspellings ⏭️
📊 radon cc — optimizer Cyclomatic-complexity gate ⏭️
📊 radon raw — optimizer Raw size metrics gate ⏭️
📊 xenon — optimizer Fail on complexity thresholds ⏭️
📊 lizard — optimizer Cyclomatic-complexity gate ⏭️
🧹 vulture — optimizer Find dead code ⏭️
🛡️ trim trailing whitespace — guardrails Strip trailing whitespace ⏭️
🛡️ fix end of files — guardrails Ensure files end in a newline ⏭️
🛡️ ruff — guardrails Lint + autofix Python (ruff) ⏭️
🛡️ ruff-format — guardrails Format Python code (ruff) ⏭️
🛡️ mypy — guardrails Static type check ⏭️
⚓ helm-docs Regenerate Helm chart README ⏭️
block non-public FE plugins Block non-public FE plugins ⏭️
🧪 pre-commit wrapper smoke tests Self-test the wrapper scripts ⏭️
🧪 rebaseline script tests Self-test the changelog re-baseline script ⏭️
📘 eslint — typescript sdk Lint + autofix JS/TS ⏭️
📘 typecheck — typescript sdk Whole-project tsc type check ⏭️
⚙️ actionlint — github workflows Lint GitHub Actions workflows ⏭️
🐳 hadolint — dockerfiles Lint Dockerfiles ⏭️
🌈 zizmor — github workflows security Security-scan GitHub Actions workflows ⏭️

@CometActions

CometActions commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

No test needed here.

This is a CI guard, not a product change — all 960 lines land in apps/opik-backend/src/test/java (TracesSchemaParity plus the pre/post-cutover suites and the MigrationUtils helper), and no changelog, DDL or runtime class moves. The traces/traces_local_v2 parity invariant it asserts is exactly the kind of drift an e2e test could never see anyway, so there is nothing here for another test to add. Worth saying: the negative tests that inject the drift and assert the guard rejects it are the part that makes this hold up.

Run

Advisory, from the QA test radar. Nothing here blocks this PR, and anything it proposes is a draft for review.

Re-checked after a push on 21 Aug 17:21 UTC — nothing the verdict depends on changed.

Comment thread apps/opik-backend/src/test/java/com/comet/opik/db/TableSchema.java
Comment thread apps/opik-backend/src/test/java/com/comet/opik/db/TracesSchemaParity.java Outdated
Comment thread apps/opik-backend/src/test/java/com/comet/opik/db/TracesSchemaParity.java Outdated
…dress review

Review feedback on the topology guard. Parity here means column names and types
and the select/expression definitions built on them — not the data, and not data
lifecycle.

Strengthened:

* Projections are compared by full definition rather than by name. Two
  projections sharing a name but not a query would leave the successor keeping
  the name and losing the meaning, which a name-only check cannot see.
* Post-cutover, columns are compared on their DEFAULT/MATERIALIZED expression as
  well as type and default kind. The wrapper is created AS the shard, so it
  starts an exact copy and any divergence is drift — a materialized column added
  to each side with a different expression previously satisfied every name and
  type assertion while computing something different on each.
* The backfill's INSERT column list is now an ordered list with duplicates
  rejected, and is checked against its SELECT projection position by position.
  ClickHouse pairs the two by position and not by name, so a column added to one
  list and not the other sends every later value to the wrong destination column
  — no syntax error, and both tables stay perfectly consistent with each other,
  so no table-to-table comparison can see it. Each projection entry must name its
  destination (bare column, or an `AS <column>` alias), which the shipped SQL
  already does.

Three new negative tests, and each new assertion was verified to fail before
being committed green:

* same-named projections with different queries. Worth noting: both trace tables
  are ReplacingMergeTree, which refuses ADD PROJECTION outright while
  deduplicate_merge_projection_mode is at its default (code 344) — so a real
  projection needs a deliberate per-table setting change. The test relaxes and
  restores it to make the leg reachable.
* a materialized column whose expression differs between shard and wrapper.
* the positional select check was mutation-tested by transposing two adjacent
  entries of the shipped SELECT projection, which it caught and every set-based
  comparison passed.

Test hygiene, also from review:

* The two one-sided column-drift tests are one @ParameterizedTest over (table,
  column); the flow was identical and only the target differed.
* The shard-only-column pair no longer hands mutated schema between two @ordered
  tests. One test now owns the column from ADD to DROP, since the "after" half
  only means anything on the state the "before" half leaves behind.

Deliberately not added: table TTL and storage policy. They are neither names,
types nor selects, the changelog sets neither on the trace tables, and the
tiered-storage policy is attached by an environment-gated migration outside this
changelog — so a guard over the changelog could not meaningfully assert it. The
scope boundary is now documented on TracesSchemaParity rather than left implicit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… wrap target

Second round of review on the topology guard. Both accepted findings were right,
and the first corrects an overreach in my earlier reasoning.

**Column types are now compared pre-cutover.** I had excluded per-column types
wholesale on the grounds that the shadow deliberately differs — but that
conflated types with codecs and defaults. Measured against the real schemas, only
6 of 31 shared columns differ in type (start_time, created_at, end_time, ttft,
duration, id_at), so the exemption is a short, enumerable list rather than most
of the table. BASELINE_TYPE_DIFFERENCES names those six with the reason each one
differs, and every other shared column is now type-checked. That catches a
precision narrowed on one table only, or a String quietly becoming
LowCardinality(String) on one side — both of which the name-set comparisons pass.

The allowlist is held honest in both directions: an entry whose columns no longer
differ fails as a stale entry, so it cannot decay into a blanket exemption for a
column nothing checks.

**The Distributed wrap target is pinned.** isDistributed() only checked the engine
prefix, so a wrapper over a different cluster, database, shard table or sharding
key would expose the same column list and pass everything. The engine is now
asserted to front `traces_local` in the same database on '{cluster}' with
sipHash64(project_id) — which also keeps the spliced statements honest against the
shipped 000003_exchange_and_wrap.sql they mirror.

Two negative tests, both verified to fail before being committed green: a
one-sided MODIFY COLUMN to LowCardinality(String), and a stale allowlist entry
produced by making the shadow's ttft Nullable again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Python SDK E2E Tests Results (Python 3.14)

296 tests   287 ✅  4m 58s ⏱️
  1 suites    8 💤
  1 files      1 ❌

For more details on these failures, see this check.

Results for commit 7996d24.

…ence

Review feedback. The allowlist excused six columns from type parity and then only
checked that their types still *differed*, so either side could drift to an
unrelated type — `traces.start_time` becoming `String` — while still "differing"
and so still being excused.

Each entry is now a BaselineTypeDifference pinning the expected type on both
`traces` and the shadow alongside the reason, and both are asserted. That closes
the drift and makes the allowlist self-documenting: the entry states exactly what
the difference is, not just that one exists. A converged pair still fails, so a
dead exemption cannot linger over a column nothing checks.

Two negative tests, one per side: the shadow's `ttft` made Nullable again (which
also removes the difference), and `traces.start_time` narrowed to DateTime64(3)
— the case a "they must differ" check would pass.

Also two Javadoc grammar fixes from the same review.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment on lines +117 to +119
static final Map<String, BaselineTypeDifference> BASELINE_TYPE_DIFFERENCES = Map.of(
"start_time", new BaselineTypeDifference("DateTime64(9, 'UTC')", "DateTime64(6, 'UTC')",
"nanosecond -> microsecond precision; nothing ingested needs finer (000101)"),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New test record bypasses builder convention

The BASELINE_TYPE_DIFFERENCES allowlist constructs BaselineTypeDifference with new instead of its required builder — should we add @Builder(toBuilder = true) to BaselineTypeDifference and use BaselineTypeDifference.builder() at these call sites, as .agents/skills/opik-backend/SKILL.md requires?

Severity

Want Baz to fix this for you? Activate Fixer You can also update your AI coding guidelines based on this comment by apply pr to [branch name]

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
`apps/opik-backend/src/test/java/com/comet/opik/db/TracesSchemaParity.java` around lines
117-119, update the `BASELINE_TYPE_DIFFERENCES` allowlist and the
`BaselineTypeDifference` record definition around lines 136 to follow the backend DTO
construction contract. Add `@Builder(toBuilder = true)` to the record and replace every
direct `new BaselineTypeDifference(...)` call in the allowlist with
`BaselineTypeDifference.builder()` construction, preserving all field values and adding
any required import.

// Each allowlisted difference is pinned on both sides. Merely requiring the types to differ would excuse an
// unrelated drift on either table, and an entry whose columns have converged is a dead exemption that must be
// removed rather than left covering a column nothing checks.
BASELINE_TYPE_DIFFERENCES.forEach((name, expected) -> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pre-cutover guard permits deleted backfill rows

The new pinning pass skips is_deleted because it iterates only BASELINE_TYPE_DIFFERENCES, so is_deleted UInt8 DEFAULT 1 can pass parity and materialize every copied row as deleted — should we pin its UInt8, DEFAULT, and 0 in the pre-cutover check, including the existing check around lines 228-233?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
`apps/opik-backend/src/test/java/com/comet/opik/db/TracesSchemaParity.java` around lines
196 and 228-233, update the pre-cutover parity logic so the shadow-only `is_deleted`
column is not validated only by name. Pin and assert its exact shadow DDL semantics:
type `UInt8`, a `DEFAULT` clause, and default expression `0`, preventing a definition
such as `UInt8 DEFAULT 1` from passing while marking all copied rows deleted. Keep the
generic column-set validation, but add the explicit metadata checks alongside the
existing shadow-only handling.

@andrescrz andrescrz left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@thiagohora
thiagohora merged commit d9a9621 into main Aug 24, 2026
70 of 71 checks passed
@thiagohora
thiagohora deleted the thiagoh/OPIK-7772-traces-ddl-topology-guard branch August 24, 2026 16:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Backend java Pull requests that update Java code 🔴 size/XL tests Including test files, or tests related like configuration.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants