Sitelet https://github.com/comet-ml/opik/pull/7953
Skip to content

[OPIK-7772] [BE] refactor: make the resolver the only way to name a trace mutation's table - #7953

Open
thiagohora wants to merge 12 commits into
mainfrom
thiagoh/OPIK-7772-mutation-routing-guard
Open

[OPIK-7772] [BE] refactor: make the resolver the only way to name a trace mutation's table#7953
thiagohora wants to merge 12 commits into
mainfrom
thiagoh/OPIK-7772-mutation-routing-guard

Conversation

@thiagohora

Copy link
Copy Markdown
Contributor

Details

Stack 3/4 for OPIK-7772 — base #7952. Post-cutover traces is a Distributed table, which rejects mutations (code 36 / 48), so every trace mutation must target the traces_local shard once the wrap is live and traces while it is not. Routing was a two-branch <if(distributed_wrap)>traces_local<else>traces<endif> conditional repeated in each mutation template, plus one site that hand-rolled the same ternary in a StringBuilder — which made a correct new mutation a matter of remembering to copy the branch, and an incorrect one indistinguishable from a correct one at a glance. This funnels the decision into one method and guards it.

  • TraceDAOImpl#tracesMutationTable() is now the single place the physical table name is decided. The mutation templates become topology-agnostic (DELETE FROM <traces_mutation_table>) and the resolver binds the resolved name.
  • deleteForRetentionBounded appends the resolver's result instead of branching on the flag itself — it was the one site outside the resolver reading it.
  • TraceMutationRoutingArchTest (ArchUnit) asserts the config flag is read in exactly one place and the routing decision made in exactly one place. These rules select the guarded method rather than its callers, so unlike TraceDeletionEventArchTest they deliberately omit allowEmptyShould — an empty selection would mean the method was renamed and the rule had silently stopped guarding.
  • TraceMutationSqlRoutingTest covers what a call-graph rule cannot see: a mutation that hardcodes a table without consulting the flag at all. It reads the SQL constants reflectively and also scans the source's single-line string literals, which is precisely the form the previous StringBuilder site took.
  • Reads and inserts are deliberately not routed through the resolver: they always go to traces, which is the Distributed wrapper post-cutover and the MergeTree before it, and is correct either way.
  • No behaviour change intended — same SQL, same target table on both topologies.

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: Authored the TraceDAO refactor (resolver, two table-name constants, template placeholders, the deleteForRetentionBounded call site) and both guard test classes. The ArchUnit formulation was corrected after the first attempt failed to compile — noCodeUnits() exposes no callMethod, so the rules use onlyBeCalled().byMethodsThat(...), which is also stricter.
  • Human verification: All four guard rules were individually verified to fail on injected violations before being committed green (see Testing), and the existing behavioural suite for the wrapped topology passes unchanged. 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=TracesDistributedWrapMutationTest                          # behavioural regression
mvn test -Dtest='TraceMutationRoutingArchTest,TraceMutationSqlRoutingTest'  # the new guards

Results: TracesDistributedWrapMutationTest 4/4 unchanged — this is the suite that drives the delete and retention paths against a real Distributed wrapper, so it is the load-bearing regression check for this refactor. New guards 4/4.

Scenarios validated:

  • Regression on the wrapped topology — black-box create/list/delete through the public API plus the two retention DAO methods invoked directly, all against a real Distributed traces over traces_local. A delete that still hit the wrapper would surface as a 500.
  • Each guard proven load-bearing — four violations injected one at a time, each confirmed to fail with a precise message:
    • a method calling TraceDAOImpl#tracesDistributedWrapEnabled() → routing rule fires, naming the method and line;
    • a method reading DatabaseAnalyticsDataModelConfig#tracesDistributedWrapEnabled() directly → flag rule fires;
    • a template changed to DELETE FROM traces → constants scan fires, naming the constant;
    • new StringBuilder("DELETE FROM traces_local WHERE (") → literal scan fires. This is the exact pre-refactor form, so the guard is confirmed to reject the code it replaced.
  • All injected violations reverted and the suite re-run green before committing.

Not run: the full backend suite (CI runs it). No video — non-visual change.

Documentation

None in this PR. The runtime-routing rule is written up in stack 4/4's playbook, and TraceDAOImpl's Javadoc now points at the guard that enforces it.

@thiagohora
thiagohora requested a review from a team as a code owner August 21, 2026 15:21
@github-actions github-actions Bot added java Pull requests that update Java code Backend tests Including test files, or tests related like configuration. 🟡 size/M 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
☕ spotless — java backend Format Java code 5.51s
Total (1 ran) 5.51s
⏭️ 42 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 — frontend Lint + autofix JS/TS ⏭️
🌐 typecheck — frontend Whole-project tsc type check ⏭️
📘 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.

Pure routing refactor: the two mutation templates swap <if(distributed_wrap)>traces_local<else>traces<endif> for <traces_mutation_table>, and the bounded retention delete moves from a StringBuilder to the same ST loop (getQueryItemPlaceHolder is 0-based, so :ws_i/:lb_i still line up with the binding loop) — the rendered SQL is the same statement in both topologies. The only user-facing path touched is trace delete, and trace-explore/trace-delete.spec.ts already asserts bulk-deleted and API-deleted traces disappear from the Logs table and return null from GET, so a wrong table name on a default (wrap-off) install fails that spec. The retention sweeps have no endpoint (RETENTION_ENABLED defaults false, catch-up runs only from RetentionCatchUpJob), and this PR's own TracesUnwrappedMutationTest / TracesDistributedWrapMutationTest pin both resolver branches against a real ClickHouse — closer to the mutation than an e2e can get. Nothing here for a new spec.

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 10:49 UTC.

Comment thread apps/opik-backend/src/main/java/com/comet/opik/domain/TraceDAO.java
Comment thread apps/opik-backend/src/main/java/com/comet/opik/domain/TraceDAO.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-mutation-routing-guard branch 2 times, most recently from 2a9dd49 to 6422807 Compare August 21, 2026 16:23
@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-mutation-routing-guard branch from 6422807 to 28fe5d5 Compare August 21, 2026 16:44
@thiagohora
thiagohora force-pushed the thiagoh/OPIK-7772-mutation-routing-guard branch from d8363c5 to c62abc2 Compare August 21, 2026 17:10
@thiagohora
thiagohora force-pushed the thiagoh/OPIK-7772-ddl-pattern-reference branch from 6904e7b to e27cf4a Compare August 21, 2026 17:20
@thiagohora
thiagohora force-pushed the thiagoh/OPIK-7772-mutation-routing-guard branch from c62abc2 to 3e6f158 Compare August 21, 2026 17:20
Comment on lines +227 to +232
void tracesIsAPlainMergeTreePreCutover() {
// Pinned, not merely "not Distributed": the helper returns "" for a missing table and any other engine
// (Memory, a plain MergeTree) would have satisfied a negative check, so an absent or wrong table passed.
assertThat(engineOf("traces"))
.as("pre-cutover `traces` must be the live ReplicatedReplacingMergeTree")
.isEqualTo("ReplicatedReplacingMergeTree");

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.

Topology documentation contradicts assertion

MergeTree in the test name and documentation misstates the table topology, while the assertion requires ReplicatedReplacingMergeTree, so the test contract conflicts with the class description — should we rename the test and update the related comments/Javadoc to describe ReplicatedReplacingMergeTree consistently?

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/infrastructure/TracesUnwrappedMutationTest.java
around lines 227-232, rename `tracesIsAPlainMergeTreePreCutover` and update its comments
to consistently describe the asserted `ReplicatedReplacingMergeTree` topology. Also
correct the class-level documentation around lines 50-51 and any related wording so it
no longer calls `traces` a plain `MergeTree` and accurately distinguishes `traces` from
the absent `traces_local` table.

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.

Commit 22b06a7 addressed this comment by renaming the test and updating the class and method documentation to consistently describe ReplicatedReplacingMergeTree and the absent traces_local table.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Accepted. When I tightened this assertion from "not Distributed" to ReplicatedReplacingMergeTree in response to your earlier finding, I left the test name and Javadoc saying "a plain MergeTree" — which is both weaker than what is enforced and, for a replicated table, simply wrong.

The method is now tracesIsTheReplicatedReplacingMergeTreePreCutover, with the display name and the surrounding class and method Javadoc updated to match.

@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Backend Tests - Integration Group 14

 31 files  + 3   31 suites  +3   5m 47s ⏱️ +5s
342 tests +11  340 ✅ +11  2 💤 ±0  0 ❌ ±0 
337 runs  +11  335 ✅ +11  2 💤 ±0  0 ❌ ±0 

Results for commit f084c37. ± Comparison against base commit 89832f9.

♻️ This comment has been updated with latest results.

@thiagohora
thiagohora force-pushed the thiagoh/OPIK-7772-mutation-routing-guard branch from 3e6f158 to f084c37 Compare August 21, 2026 19:16
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
@thiagohora
thiagohora force-pushed the thiagoh/OPIK-7772-mutation-routing-guard branch from f084c37 to 74da49b Compare August 25, 2026 08:36
thiagohora and others added 6 commits August 25, 2026 10:58
…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>
…race mutation's table

Post-cutover `traces` is a Distributed table, which rejects mutations (code 36 /
48), so every trace mutation must target the `traces_local` shard once the wrap
is live and `traces` while it is not. Routing was a two-branch
`<if(distributed_wrap)>traces_local<else>traces<endif>` conditional repeated in
every mutation template, plus one site that hand-rolled the same ternary in a
StringBuilder — which made a correct new mutation a matter of remembering to copy
the branch, and an incorrect one indistinguishable from a correct one at a glance.

Funnels the decision into TraceDAOImpl#tracesMutationTable(), the single place the
name is chosen:

* the mutation templates become topology-agnostic (`DELETE FROM
  <traces_mutation_table>`), with the resolver binding the resolved name;
* deleteForRetentionBounded appends the resolver's result instead of branching on
  the flag itself — it was the one site outside the resolver reading it.

Two guards keep it that way. TraceMutationRoutingArchTest asserts the flag is
read in exactly one place and the routing decision made in exactly one place;
these rules select the guarded method rather than its callers, so unlike
TraceDeletionEventArchTest they deliberately omit allowEmptyShould — an empty
selection would mean the method was renamed and the rule had stopped guarding.
TraceMutationSqlRoutingTest covers what a call-graph rule cannot see, a mutation
that hardcodes a table without consulting the flag at all: it reads the SQL
constants reflectively and also scans the source's single-line string literals,
which is the form the previous StringBuilder site took.

All four rules were verified to fail on injected violations before being
committed green, and TracesDistributedWrapMutationTest — which drives the delete
and retention paths against a real Distributed wrapper — passes unchanged.

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

Review feedback on the mutation-routing PR. All three findings were real.

**The bounded retention delete is now a declared template.** It was still built
with a StringBuilder, which SKILL.md forbids outright and which hid the statement
from both its declaration site and the routing guard that reads these constants.
DELETE_FOR_RETENTION_BOUNDED now sits beside its unbounded sibling, with the
OR-ed per-workspace predicates as a `getQueryItemPlaceHolder` template loop —
the same idiom BATCH_INSERT and the other variable-arity queries in this DAO use.
Every value stays bound; the table still comes from the resolver placeholder.

**The unwrapped branch of the resolver had no coverage at all.** The reviewer was
right, and more sharply than it first appeared: `TracesDistributedWrapMutationTest`
only runs with the wrap on, and nothing else in the repository calls
`deleteForRetentionBounded` — so the SQL this PR rewrites had no test on the
default topology. `TracesUnwrappedMutationTest` is its pre-cutover counterpart:
delete-by-id, both retention sweeps, a multi-workspace case that forces the
template loop to render more than one branch and its separator, and a guard
asserting `traces` is a MergeTree and `traces_local` does not exist, so a
mis-routed mutation could not have quietly succeeded.

Verified by inverting the resolver's ternary: 4 of 5 tests fail with "Table
opik.traces_local does not exist", which is precisely the bug that used to stay
green.

**The SQL detector missed qualified and quoted targets.** `analytics.traces`
reduced to `analytics` and was not flagged, so a qualified mutation escaped the
guard entirely. `normalizeTarget` now strips quoting, drops the database
qualifier and trims trailing punctuation, with 16 parameterized cases covering
qualified, backtick-quoted, double-quoted, upper-case and semicolon-terminated
forms, plus the complement that must not be flagged — the resolver placeholder,
`traces_local_v2`, `traces_pre_cutover_backup`, `trace_threads` and `spans`.

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

Review feedback: the multi-workspace test seeded traces in only one workspace, so
it rendered the extra OR branches without ever verifying they select correctly. A
broken separator, a mis-numbered bind, or a single shared floor would all have
passed it.

It now seeds two workspaces through the public API and asserts *selective*
deletion: both traces sit inside the shared week window, so the toMonday bounds
cannot be what separates them — the only thing that can is each workspace's own
`:lb_i`. The first workspace's floor sits below its trace, the second's above its
own, and one call must delete the first and spare the second.

Verified load-bearing by collapsing the per-workspace bind to the first
workspace's floor: the test fails on "its workspace's floor sits above this trace,
so the same statement must spare it".

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

Review feedback, both fair.

The suite asserts the pre-cutover topology as a *precondition*, so it has to own
that topology rather than inherit whatever a shared container is in. Reuse is
enabled in CI, so a container left wrapped by anything would have failed this
suite for environmental reasons rather than real ones. Now dedicated, non-reused
ClickHouse and ZooKeeper on their own network, matching
TracesDistributedWrapMutationTest, and stopped in afterAll.

The topology guard also accepted too much: `doesNotContain("Distributed")` passes
for the helper's "" sentinel when the table is missing, and for any other engine.
Pinned to ReplicatedReplacingMergeTree, so an absent or wrong table fails.

Renamed deleteForRetentionBoundedAppliesPerWorkspaceLowerBounds — the previous
name mangled the possessive.

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

Review feedback. When I tightened the assertion from "not Distributed" to
ReplicatedReplacingMergeTree, the test name and its Javadoc kept saying "a plain
MergeTree" — which is both weaker and, for a replicated table, wrong. The name,
display name and surrounding docs now say ReplicatedReplacingMergeTree, matching
what the test enforces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@thiagohora
thiagohora force-pushed the thiagoh/OPIK-7772-mutation-routing-guard branch from 74da49b to 22b06a7 Compare August 25, 2026 09:54
Comment on lines +138 to +143
void afterAll() {
wireMock.server().stop();
clickHouseContainer.stop();
zookeeperContainer.stop();
network.close();
}

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.

Unreleased Redis/MySQL containers exhaust CI resources

afterAll() stops WireMock, ClickHouse, and ZooKeeper but leaves redisContainer and mysqlContainer running because TestDropwizardAppExtensionUtils receives only their URLs and cannot tear them down. Since withReuse(true) requires environment-level opt-in, repeated same-JVM runs accumulate live database containers — should we stop them here or establish an explicit shared reusable-container lifecycle?

Severity web_search

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/infrastructure/TracesUnwrappedMutationTest.java`
around lines 138-143, update `afterAll()` so it tears down every container started by
this test, including `redisContainer` and `mysqlContainer`. Add explicit cleanup for
those containers alongside the existing WireMock, ClickHouse, and ZooKeeper shutdowns,
using a structure that preserves cleanup of later resources if an earlier shutdown
throws. Do not rely on `TestDropwizardAppExtension` or Testcontainers reuse to own these
containers.

Comment on lines +243 to +245
"SELECT engine FROM system.tables WHERE database = :database AND name = :table")
.bind("database", DATABASE_NAME)
.bind("table", table);

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.

Unattributed ClickHouse topology queries

engineOf() declares the system.tables query as a normal Java string without SETTINGS log_comment, so both executions are logged with an empty system.query_log.log_comment — should we convert it to a text block and add SETTINGS log_comment = 'traces_unwrapped_mutation_test:engine_of' while retaining the :database and :table bindings, as .agents/opik-backend/SKILL.md and .agents/skills/opik-backend/clickhouse.md require?

Severity web_search

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/infrastructure/TracesUnwrappedMutationTest.java
around lines 243-245, update the `engineOf()` ClickHouse `system.tables` query to follow
repository conventions. Declare the SQL as a Java text block and add `SETTINGS
log_comment = 'traces_unwrapped_mutation_test:engine_of'`, while retaining the existing
named `:database` and `:table` bindings.


/** Mirrors {@code TracesDistributedWrapMutationTest.RetentionWindow}; see its Javadoc for the ±1s rationale. */
@Builder(toBuilder = true)
private record RetentionWindow(UUID lowerBound, UUID middleId, UUID cutoffId) {

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.

Test fixture permits invalid null windows

RetentionWindow leaves required lowerBound, middleId, and cutoffId nullable, so a builder call can create a partially initialized window — should we annotate them with Lombok @NonNull and add the import, 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/infrastructure/TracesUnwrappedMutationTest.java`
around lines 252–254, update the `RetentionWindow` record used by `aroundNow()` and
the retention tests so its required `lowerBound`, `middleId`, and `cutoffId` components
cannot be null. Import Lombok’s `@NonNull` and annotate all three record components,
ensuring partially initialized builder instances fail immediately.

Base automatically changed from thiagoh/OPIK-7772-ddl-pattern-reference to main August 25, 2026 10:19
Comment on lines +2015 to +2019
AND id NOT IN (
SELECT trace_id FROM experiment_items
WHERE workspace_id IN :workspace_ids_flat
AND trace_id >= :min_lower_bound
AND trace_id \\< :cutoff_id

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.

Cross-workspace retention deletes are skipped

The bounded retention delete excludes a trace_id globally instead of correlating experiment_items to each workspace, so an experiment-linked ID in workspace A prevents deleting the eligible match in workspace B and leaves retention data behind — should we correlate experiment_items.workspace_id with each workspace predicate rather than use the global workspace_ids_flat/min_lower_bound range?

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/main/java/com/comet/opik/domain/TraceDAO.java around lines
2015-2019, update the `DELETE_FOR_RETENTION_BOUNDED` query used by
`deleteForRetentionBounded` so experiment-linked trace IDs are excluded per workspace
rather than globally. Move or replicate the `experiment_items` exclusion into each
workspace-specific OR predicate, correlating `experiment_items.workspace_id` with
`:ws_<item.index>` and using that item’s `:lb_<item.index>` bound; remove the global
`workspace_ids_flat`/`min_lower_bound` exclusion. Keep the query parameterized and
compatible with the existing StringTemplate loop and add or update tests covering reused
trace IDs across workspaces.

Comment on lines +399 to +401
for (var sql : inject) {
execute(sql);
}

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.

Partial injection contaminates later tests

The second execute(sql) runs before try, so an exception skips finally, leaving the first DDL and allowProjections(...) settings applied and contaminating the next ordered test; should we move injection into the protected region and make cleanup best-effort so every completed change is restored?

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/TracesSchemaParityPreCutoverTest.java`
around lines 399-401, refactor `assertDriftIsCaught` so the injection loop runs inside
the `try`, ensuring partial DDL setup always reaches cleanup. Make cleanup best-effort
by attempting every restore command even if one fails, preserving the original failure
and attaching cleanup errors as suppressed when appropriate. Also place the
projection-setting setup in `projectionQueryDriftIsCaught` under the same protected
cleanup path so `allowProjections` cannot leave table settings enabled for later ordered
tests.

static final ArchRule the_wrap_flag_is_read_in_exactly_one_place = methods()
.that().areDeclaredIn(DatabaseAnalyticsDataModelConfig.class)
.and().haveName(CONFIG_FLAG)
.should().onlyBeCalled().byMethodsThat(name(CONFIG_FLAG))

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.

Same-named helper bypasses sole-reader guard

byMethodsThat(name(CONFIG_FLAG)) matches any method with that name regardless of declaring class, so OtherDao#tracesDistributedWrapEnabled() calling DatabaseAnalyticsDataModelConfig.tracesDistributedWrapEnabled() bypasses the required TraceDAOImpl#tracesDistributedWrapEnabled single-reader invariant — should we also require areDeclaredIn(TraceDAOImpl.class) or an equivalent owner predicate?

Severity web_search

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/domain/TraceMutationRoutingArchTest.java`
around lines 41-48, strengthen `the_wrap_flag_is_read_in_exactly_one_place` so its
caller predicate verifies both the `tracesDistributedWrapEnabled` name and
`TraceDAOImpl` as the declaring class. Compose `name(CONFIG_FLAG)` with an owner/class
predicate such as `areDeclaredIn(TraceDAOImpl.class)`, or use the equivalent ArchUnit
predicate, so similarly named methods in other classes cannot bypass the single-reader
invariant.

@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Backend Tests - Integration Group 13

 53 files   - 2   53 suites   - 2   4m 25s ⏱️ -55s
323 tests +4  321 ✅ +4  2 💤 ±0  0 ❌ ±0 
315 runs  +1  313 ✅ +1  2 💤 ±0  0 ❌ ±0 

Results for commit 3dd8f83. ± Comparison against base commit 14f554b.

This pull request removes 5 and adds 9 tests. Note that renamed tests count towards both.
com.comet.opik.api.resources.v1.priv.ExperimentsResourceCustomConfigurationTest ‑ findExperimentsWithForceSortingBypassesLimit
com.comet.opik.api.resources.v1.priv.ExperimentsResourceCustomConfigurationTest ‑ findExperimentsWithSortingDisabled
com.comet.opik.api.resources.v1.priv.TraceThreadOnlineScoringAgenticToolsE2ETest ‑ runsSizeAggregateAndScoresThreadWhenAgenticToolsEnabled(LlmProviderFactory)
com.comet.opik.infrastructure.bi.OpikGuiceyLifecycleEventListenerTest$FirstStartupTest ‑ shouldNotifyEvent(UsageReportService)
com.comet.opik.infrastructure.bi.OpikGuiceyLifecycleEventListenerTest$SecondStartupTest ‑ shouldNotNotifyEvent(UsageReportService)
com.comet.opik.domain.ProjectLastUpdatedTraceBufferServiceTest ‑ flushSkipsMalformedMemberButDrainsBuffer
com.comet.opik.domain.ProjectLastUpdatedTraceBufferServiceTest ‑ flushWritesOneBatchPerWorkspace
com.comet.opik.domain.ProjectLastUpdatedTraceBufferServiceTest ‑ recordKeepsClusterWideMaxAcrossCalls
com.comet.opik.domain.ProjectLastUpdatedTraceBufferServiceTest ‑ recordThenFlushPersistsMarkerToProject
com.comet.opik.domain.mcpoauth.McpOAuthScrubJobTest ‑ scrubsExpiredArtifactsFromOAuthFlow
com.comet.opik.infrastructure.TracesDistributedWrapMutationTest ‑ deleteByIdRemovesTraceThroughTheLocalShardUnderTheDistributedWrap
com.comet.opik.infrastructure.TracesDistributedWrapMutationTest ‑ deleteForRetentionBoundedRunsAgainstTheLocalShard
com.comet.opik.infrastructure.TracesDistributedWrapMutationTest ‑ deleteForRetentionRunsAgainstTheLocalShard
com.comet.opik.infrastructure.TracesDistributedWrapMutationTest ‑ distributedTracesRejectsDirectMutation

♻️ This comment has been updated with latest results.

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

No blockers on my end.

* {@code DELETE} / {@code ALTER} / {@code OPTIMIZE} have to move to the shard.
*/
@DisplayName("Trace Mutation SQL Routing")
class TraceMutationSqlRoutingTest {

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.

I wonder if there's an easy alternative to convert these tests into an arch test instead. It seems to fit better there.

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