[OPIK-7772] [BE] refactor: make the resolver the only way to name a trace mutation's table - #7953
[OPIK-7772] [BE] refactor: make the resolver the only way to name a trace mutation's table#7953thiagohora wants to merge 12 commits into
Conversation
⏱️ pre-commit per-hook timing
⏭️ 42 skipped (no matching files changed)
|
|
No test needed here. Pure routing refactor: the two mutation templates swap 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. |
f664c8a to
14239c5
Compare
2a9dd49 to
6422807
Compare
d9a13e7 to
039831f
Compare
6422807 to
28fe5d5
Compare
d8363c5 to
c62abc2
Compare
6904e7b to
e27cf4a
Compare
c62abc2 to
3e6f158
Compare
| 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"); |
There was a problem hiding this comment.
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?
Want Baz to fix this for you? Activate Fixer
Other fix methods
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
3e6f158 to
f084c37
Compare
…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>
89832f9 to
9cbbe8f
Compare
f084c37 to
74da49b
Compare
…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>
74da49b to
22b06a7
Compare
| void afterAll() { | ||
| wireMock.server().stop(); | ||
| clickHouseContainer.stop(); | ||
| zookeeperContainer.stop(); | ||
| network.close(); | ||
| } |
There was a problem hiding this comment.
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?
Want Baz to fix this for you? Activate Fixer
Other fix methods
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.
| "SELECT engine FROM system.tables WHERE database = :database AND name = :table") | ||
| .bind("database", DATABASE_NAME) | ||
| .bind("table", table); |
There was a problem hiding this comment.
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?
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
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) { |
There was a problem hiding this comment.
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?
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
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.
| 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 |
There was a problem hiding this comment.
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?
Want Baz to fix this for you? Activate Fixer
Other fix methods
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.
| for (var sql : inject) { | ||
| execute(sql); | ||
| } |
There was a problem hiding this comment.
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?
Want Baz to fix this for you? Activate Fixer
Other fix methods
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)) |
There was a problem hiding this comment.
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?
Want Baz to fix this for you? Activate Fixer
Other fix methods
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.
Backend Tests - Integration Group 13 53 files - 2 53 suites - 2 4m 25s ⏱️ -55s 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.♻️ This comment has been updated with latest results. |
| * {@code DELETE} / {@code ALTER} / {@code OPTIMIZE} have to move to the shard. | ||
| */ | ||
| @DisplayName("Trace Mutation SQL Routing") | ||
| class TraceMutationSqlRoutingTest { |
There was a problem hiding this comment.
I wonder if there's an easy alternative to convert these tests into an arch test instead. It seems to fit better there.
Details
Stack 3/4 for OPIK-7772 — base #7952. Post-cutover
tracesis aDistributedtable, which rejects mutations (code 36 / 48), so every trace mutation must target thetraces_localshard once the wrap is live andtraceswhile 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 aStringBuilder— 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.deleteForRetentionBoundedappends 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 unlikeTraceDeletionEventArchTestthey deliberately omitallowEmptyShould— an empty selection would mean the method was renamed and the rule had silently stopped guarding.TraceMutationSqlRoutingTestcovers 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 previousStringBuildersite took.traces, which is theDistributedwrapper post-cutover and theMergeTreebefore it, and is correct either way.Change checklist
Issues
AI-WATERMARK
AI-WATERMARK: yes
TraceDAOrefactor (resolver, two table-name constants, template placeholders, thedeleteForRetentionBoundedcall site) and both guard test classes. The ArchUnit formulation was corrected after the first attempt failed to compile —noCodeUnits()exposes nocallMethod, so the rules useonlyBeCalled().byMethodsThat(...), which is also stricter.Testing
Environment: local, Docker 29.7.2 (linux/aarch64), Corretto 25.0.3, Maven 3.9.9, from
apps/opik-backend.Results:
TracesDistributedWrapMutationTest4/4 unchanged — this is the suite that drives the delete and retention paths against a realDistributedwrapper, so it is the load-bearing regression check for this refactor. New guards 4/4.Scenarios validated:
Distributedtracesovertraces_local. A delete that still hit the wrapper would surface as a 500.TraceDAOImpl#tracesDistributedWrapEnabled()→ routing rule fires, naming the method and line;DatabaseAnalyticsDataModelConfig#tracesDistributedWrapEnabled()directly → flag rule fires;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.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.