Skip to content

[OPIK-7772] [BE] test: add the topology-aware traces DDL pattern and its reference migration - #7952

Merged
thiagohora merged 6 commits into
mainfrom
thiagoh/OPIK-7772-ddl-pattern-reference
Aug 25, 2026
Merged

[OPIK-7772] [BE] test: add the topology-aware traces DDL pattern and its reference migration#7952
thiagohora merged 6 commits into
mainfrom
thiagoh/OPIK-7772-ddl-pattern-reference

Conversation

@thiagohora

Copy link
Copy Markdown
Contributor

Details

Stack 2/4 for OPIK-7772 — base #7951, review that one first. Establishes the shape every future traces schema change ships in: two complementary changesets guarded on the same runtime fact — whether traces_local exists — so exactly one branch executes and the other is recorded MARK_RAN. Both gates from #7951 now apply the same single file to their own topology and prove the correct branch ran, which is what turns the pattern from a convention into something CI can enforce.

  • Four load-bearing details, each now asserted: a sqlCheck against system.tables rather than tableExists, because the guard has to read the runtime topology, which no changelog records; onFail:MARK_RAN, so the skipped branch is marked applied without executing and a later startup never retries it against the wrong topology; onError:HALT, so an unevaluable precondition stops rather than guesses; and IF [NOT] EXISTS throughout, so a re-run or partially-applied branch is idempotent.
  • This also confirms liquibase-clickhouse 0.7.2 honours these preconditions — the fact the whole pattern rests on. A version bump that broke it now fails CI rather than production.
  • The reference migration covers the two cases we have historically shipped, one of each kind: a read-facing field (a MATERIALIZED column → shard and wrapper) and a storage-only skip index (→ shard alone). A derived column is used deliberately: read-facing enough to exercise the wrapper branch, while needing no cutover-backfill entry, so the fixture never has to edit shipped cutover SQL. The preserved-column → backfill-list obligation is already covered by a negative test in [OPIK-7772] [BE] test: guard traces DDL across pre/post-cutover topologies in CI #7951.
  • A negative control carries the same intent written the ordinary un-guarded way, as one unconditional ALTER TABLE traces. It applies cleanly on both topologies and is wrong on both — pre-cutover it never reaches the shadow, post-cutover it only reaches the wrapper — and both gates assert they reject it. That is what makes the pattern load-bearing rather than ceremony.
  • These are reference migrations, not actual migrations, so they are test-only. They sit under src/test/resources, where the shipped changelog's includeAll over liquibase/db-app-analytics/migrations/ cannot reach them, so no deployment can apply them. They carry no migration number and use the changeset author opik-7772-test-fixture, so a ledger row from a fixture is unmistakable — and since Liquibase identity is id + author + filename, and the fixture path differs from the shipped one, a collision was already impossible.
  • Each gate additionally asserts the shipped changelog is still fully applied once a fixture has run. Listing its unrun changesets revalidates every recorded checksum, so a fixture that had disturbed a shipped ledger row fails there rather than in an unrelated suite later.
  • No shipped migration is added or edited.

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 the two fixture changelogs and their migrations, the TracesDdlReferenceFixture constants/ledger helper, and the fixture tests added to both gates. The MARK_RAN behaviour and the fact that a MATERIALIZED column can be added to a Distributed wrapper were both verified empirically against a real container rather than assumed.
  • Human verification: The author chose the test-only fixture approach over shipping a real reference migration, and pushed back on the fixture's interaction with the changelog ledger — which is what prompted the isolation notes and the "shipped changelog intact" assertions in this PR. All tests executed locally (see Testing). 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='TracesSchemaParity*Test'

Result: 21/21 (pre-cutover 10, post-cutover 11), ~30s per gate.

Scenarios validated:

  • Correct branch per topology — pre-cutover the pre-cutover changeset is EXECUTED and the post-cutover one MARK_RAN; post-cutover, the reverse. Read from the DATABASECHANGELOG EXECTYPE, filtered by the fixture author.
  • Where the change landed — pre-cutover the field and the index reach both traces and the shadow; post-cutover the field reaches shard and wrapper while the index reaches the shard only and is asserted absent from the wrapper.
  • Readability — post-cutover the field is SELECTed through the wrapper, so the branch is proven to have produced a working column, not just matching metadata.
  • Idempotency — re-applying the fixture changelog leaves nothing unrun and parity intact.
  • Un-guarded negative control — applied on both topologies; asserted that it does reach the live table (i.e. it genuinely fails silently) and does not reach the shadow / shard, and that the gate rejects it. Cleaned up and parity re-asserted afterwards.
  • Ledger isolation — the shipped changelog is still fully applied, with nothing pending or checksum-invalidated, after both fixtures have run.

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 this reference implements lands in stack 4/4 as apps/opik-backend/docs/traces-schema-ddl.md; the fixture headers already point at it.

@thiagohora
thiagohora requested a review from a team as a code owner August 21, 2026 15:18
@github-actions github-actions Bot added java Pull requests that update Java code Backend tests Including test files, or tests related like configuration. 🟠 size/L 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 30.84s
🌐 eslint — frontend Lint + autofix JS/TS 6.79s
☕ spotless — java backend Format Java code 5.38s
⚓ helm-docs Regenerate Helm chart README 3.95s
Total (4 ran) 46.96s
⏭️ 39 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 ⏭️
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.

All 10 files are under apps/opik-backend/src/test — the schema-parity harness, the reference topology-aware migration and the unguarded counter-example are test fixtures, and no shipped changelog references traces-ddl-reference or traces-ddl-unguarded, so a running Opik never applies them. There is no runtime behaviour for an e2e test to observe, and the pattern this establishes is already asserted by the pre/post-cutover parity tests it ships with (including their negative cases). Nothing needed from QA here.

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 25 Aug 09:55 UTC — nothing the verdict depends on changed.

Comment thread apps/opik-backend/src/test/java/com/comet/opik/db/TracesDdlReferenceFixture.java Outdated
@thiagohora
thiagohora force-pushed the thiagoh/OPIK-7772-ddl-pattern-reference branch from f664c8a to 14239c5 Compare August 21, 2026 16:02
@thiagohora
thiagohora force-pushed the thiagoh/OPIK-7772-ddl-pattern-reference branch from d9a13e7 to 039831f Compare August 21, 2026 16:44
@thiagohora
thiagohora force-pushed the thiagoh/OPIK-7772-ddl-pattern-reference branch from 6904e7b to e27cf4a Compare August 21, 2026 17:20
Base automatically changed from thiagoh/OPIK-7772-traces-ddl-topology-guard to main August 24, 2026 16:11
thiagohora and others added 5 commits August 25, 2026 09:57
…d builders

Review feedback on the type-parity round.

**`is_deleted` was checked by nothing.** The type comparison iterates the columns
of `traces` and looks each up on the shadow, so a shadow-only column is
structurally unreachable by it — and it is not in the type allowlist either. That
left the one column where the *default* is the entire contract completely
unpinned: the cutover backfill deliberately omits `is_deleted` so it takes its
default, so `DEFAULT 1` would materialise every copied row as a
ReplacingMergeTree tombstone. Silent, total data loss at swap time.

SHADOW_ONLY_COLUMNS is now a pinned contract (type, default kind, default
expression, and the reason) rather than a bare name set, asserted in full, with a
negative test flipping the default to 1. The name-set comparisons read its key
set, so there is still one source of truth for what may differ.

**Records use builders.** SKILL.md requires `@Builder(toBuilder = true)` and
builder construction, and the rationale applies directly here rather than being
ceremony: BaselineTypeDifference takes three same-typed Strings, so a positional
swap of tracesType and shadowType would silently invert the assertion it exists to
make. TableSchema and its nested Column / SkipIndex / Projection records get the
same treatment — TableSchema's constructor took five consecutive Strings.

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

Establishes the shape every future `traces` schema change ships in: two
complementary changesets guarded on the same runtime fact — whether
`traces_local` exists — so exactly one branch executes and the other is recorded
MARK_RAN. The guard is a formatted-SQL sqlCheck against system.tables rather than
a tableExists precondition because it has to read the *runtime* topology, which
no changelog records, and both branches are IF [NOT] EXISTS so any re-run or
partially-applied branch is idempotent.

The reference migration demonstrates the two cases we have historically shipped,
one of each kind: a read-facing field (a MATERIALIZED column, applied to the
shard and the Distributed wrapper) and a storage-only skip index (applied to the
shard alone). A derived column is used deliberately — read-facing enough to
exercise the wrapper branch, while needing no cutover-backfill entry, so the
fixture never has to edit shipped cutover SQL.

Both topology gates now apply the same single file and assert the correct branch
executed while the other was recorded MARK_RAN — which also confirms
liquibase-clickhouse 0.7.2 honours these preconditions, the fact the pattern
rests on — that re-applying is a no-op, and that the field is genuinely readable
through the wrapper afterwards.

A negative-control fixture carries the same intent written the ordinary
un-guarded way, as one unconditional ALTER TABLE traces. It applies cleanly on
both topologies and is wrong on both — pre-cutover it never reaches the shadow,
post-cutover it only reaches the wrapper — and both gates assert they reject it.
That is what makes the pattern load-bearing rather than ceremony.

The fixtures are kept off anything an install runs: they live under
src/test/resources so the shipped changelog's includeAll cannot reach them, they
carry no migration number, and their changeset author is `opik-7772-test-fixture`
so the ledger rows they write in a throwaway container cannot collide with a
shipped changeset id. Each gate additionally asserts the shipped changelog is
still fully applied once a fixture has run, which revalidates its recorded
checksums too.

No shipped migration is added or edited.

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

Review feedback on the DDL pattern fixtures.

The reference migration's ALTERs omitted `ON CLUSTER '{cluster}'`. That is a real
defect in a file whose entire purpose is to be copied: without it the DDL reaches
only the node the migration connected to, leaving other replicas short of the
column while the changeset is recorded as applied. It matters twice over here,
because the guard branch is chosen from a *local* system.tables read — so on a
divergent cluster one node can record MARK_RAN for a topology the others are not
in. Every shipped traces/spans ALTER already uses it, and migrations.md requires
it. Added to both branches and every rollback.

The un-guarded negative control gets it too, so that fixture is now written
correctly in every respect *except* the missing precondition guard. That keeps it
sharp: when the gates reject it, they are rejecting the absent guard and nothing
else.

TracesDdlReferenceFixture#execType now binds the changeset id and author through
a PreparedStatement instead of interpolating them. They are values in a predicate,
which is what SKILL.md's SQL rule reserves for binding. Identifiers elsewhere in
these gates stay interpolated because ClickHouse accepts no parameter in a table
or column position — that limitation is now stated where it applies rather than
left as an apparent inconsistency.

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

Review feedback on the fixture assertions, which checked presence where they
should have checked the contract.

* The reference field is asserted on type and default kind (UInt64 MATERIALIZED)
  on every target in both topologies, not just by name. A column of that name
  arriving as UInt32, or as an ALIAS, would have satisfied a presence check while
  breaking what the migration declares.
* The reference index is compared as a full SkipIndex record — set(0) on `name` at
  granularity 1 — so an index of the right name with the wrong type, expression or
  granularity now fails. The wrapper-absence check is unchanged. This matches what
  assertPreCutoverParity already does for shared indices and projections.
* The post-cutover readability check evaluated nothing: SELECT ... LIMIT 0 proves
  the wrapper can resolve the column, not that the expression behind it works, so
  a materialized column with a valid name and a broken definition passed. One row
  is now written through the wrapper and its computed value read back — the
  reference declares MATERIALIZED length(name), so a known name must yield its
  length. The LIMIT 0 sweep over all 32 shard columns stays; that one is about the
  wrapper exposing the whole list, which is a different property.

The expectations live on TracesDdlReferenceFixture beside the names they qualify,
so both gates assert the same contract.

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

Review feedback. The fixture contract pinned type and default kind but not the
expression, so a MATERIALIZED UInt64 computing something other than length(name)
satisfied every assertion while producing different values per topology — the same
drift assertPostCutoverParity compares expressions to catch. Now asserted in both
topology helpers against a single declared constant.

Also standardised "un-guarded" to "unguarded" across the tests, fixtures and
display names.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@thiagohora
thiagohora force-pushed the thiagoh/OPIK-7772-ddl-pattern-reference branch from 89832f9 to 9cbbe8f Compare August 25, 2026 08:36
@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Python SDK E2E Tests Results (Python 3.14)

296 tests  ±0   288 ✅  - 1   4m 50s ⏱️ +50s
  1 suites ±0     8 💤 +1 
  1 files   ±0     0 ❌ ±0 

Results for commit 657dbcc. ± Comparison against base commit 634f315.

This pull request skips 1 test.
tests.e2e.evaluation.test_test_suite_agentic ‑ test_test_suite_agentic__assertion_about_span_error__detects_failure

♻️ This comment has been updated with latest results.

…ding titles

Review feedback on the parity gates.

**Cleanup is now guaranteed.** The negative tests restored the schema only after
their assertions, so a failing one left its drift in place and every later
@ordered test in the same container failed for a reason unrelated to what it
asserts — burying the real failure. assertDriftIsCaught injects, asserts and
restores in a finally, then re-asserts parity so the cleanup is proven rather than
assumed. It also removes the repetition across seven negative tests; the
projection case benefits most, since it restores two table settings as well as
dropping the projections.

**Teardown no longer masks setup failures.** If migration or connection setup
threw before `connection` was assigned, @afterall's close() raised an NPE over the
real error. Now null-safe, with the container stops in a finally so they run even
if closing fails.

**Two titles were wrong, and the second materially so.** "marks the other one run"
understates MARK_RAN, which records a changeset as applied *without executing its
statements*; the titles now say so. And "an unguarded traces migration is
rejected" was backwards: the migration applies perfectly cleanly — that is the
entire premise of this ticket — and it is the gate that rejects the drift it
leaves behind. Retitled to say that, since a reader scanning test names would
otherwise take away the opposite of the thing being demonstrated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
andrescrz
andrescrz previously approved these changes Aug 25, 2026
@thiagohora
thiagohora merged commit ea1e2f1 into main Aug 25, 2026
77 of 78 checks passed
@thiagohora
thiagohora deleted the thiagoh/OPIK-7772-ddl-pattern-reference branch August 25, 2026 10:19
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