Skip to content

[OPIK-8230] [BE] fix: scope the trace delete mutation to the batch's own partitions - #8131

Open
thiagohora wants to merge 1 commit into
mainfrom
thiagohora/OPIK-8230/scope-delete-mutation-to-partitions
Open

[OPIK-8230] [BE] fix: scope the trace delete mutation to the batch's own partitions#8131
thiagohora wants to merge 1 commit into
mainfrom
thiagohora/OPIK-8230/scope-delete-mutation-to-partitions

Conversation

@thiagohora

Copy link
Copy Markdown
Contributor

Details

A ClickHouse mutation is registered against every active part of the target table before its WHERE clause is considered at all — the existing partition predicate (OPIK_6901) prunes which rows a matched part rewrites, not which parts the mutation visits. Post-cutover traces_local holds ~1,900 partitions / ~3,650 parts, so an ordinary delete matching a handful of rows was registered against all of them at ~19 ms/part of fixed overhead. That is what pushed trace deletes past max_execution_time (Code 159 TIMEOUT_EXCEEDED), where the statement errors and skips the TracesDeleted cascade and the deletion-events bridge write even though the mutation completes and the rows are in fact deleted.

This scopes each statement to the partitions its own batch resolves to via IN PARTITION, emitting one statement per partition and falling back to today's unbounded form when the batch can't be derived exactly, or when the target table isn't actually partitioned.

  • WeeklyPartitions#groupByPartition (new) groups ids by partition rather than naming their union — IN PARTITION accepts exactly one partition per statement, and ClickHouse's IN PARTITION p1, p2, … form is for composite keys, not a list of distinct partitions.
  • The fallback also triggers pre-wrap: the legacy traces has no partition key at all, so IN PARTITION against it is a hard Code 248 INVALID_PARTITION_VALUE, not a no-op. deleteBatch gates on tracesMutationTable()'s own resolved name — never the wrap flag directly, which TraceMutationRoutingArchTest forbids.
  • Both forms share one SQL template via <if(partition)>, matching the pre-diff code's own conditional-fragment pattern rather than duplicating the WHERE/SETTINGS body across two near-identical constants.
  • WeeklyPartitions#of, the flat-union predecessor, is removed — it had no production callers left once TraceDAO switched over. Its unique boundary-case coverage was migrated to groupByPartition rather than dropped.

Change checklist

  • User facing
  • Documentation update

Issues

  • Resolves #
  • OPIK-8230

AI-WATERMARK

AI-WATERMARK: yes

  • Tools: Claude Code
  • Model(s): Claude Opus 5
  • Scope: Implementation, tests, and commit/PR description. Three /code-review passes were run against the branch and their findings triaged — fixes applied for the dead of() method, a vacuous test assertion, stale {@link} references, SQL-template duplication, and a mutable collection escaping partitionsOf. Three findings were deliberately not fixed and are documented at their call sites instead (see Known tradeoffs below).
  • Human verification: Required before merge. The delete path is production-critical and this changes the SQL shape of every trace delete. Reviewer attention is most valuable on the two accepted tradeoffs below, and on whether criteria 3/4/7 (unverified, see Testing) should gate the merge.

Testing

All run locally against real ClickHouse via testcontainers (image altinity/clickhouse-server:26.3.16.10001.altinitystable, production's exact version), on the final rebased commit:

mvn -q clean spotless:apply test-compile
mvn -q -Dtest=TracesPartitionPruningMutationTest,TracesLegacyTablePruningMutationTest,\
TraceMutationRoutingArchTest,WeeklyPartitionsTest test
Suite Result
WeeklyPartitionsTest 17/17
TracesPartitionPruningMutationTest 8/8
TracesLegacyTablePruningMutationTest 3/3
TraceMutationRoutingArchTest 3/3

Scenarios validated:

  • Bounded vs unbounded part footprint — a scoped delete touches measurably fewer parts than the unbounded fallback measured moments later in the same test.
  • Row-level equivalence — every era's row is actually gone; a bystander in the same project survives.
  • Far-future id → two partitions — one such id emits two separate IN PARTITION statements (honest week + legacy 32-bit wrap), each pairs_size=1.
  • Fallback still deletes — non-UUIDv7 and past-DateTime64-ceiling ids, on both the partitioned and legacy tables.
  • Per-chunk independence — a batch spanning two ANALYTICS_DELETE_BATCH_SIZE chunks derives partitions per chunk, not once per request.
  • Routing guard intact — the mutation table is still decided in exactly one place.

Test-infrastructure fix worth flagging: TracesPartitionPruningMutationTest previously asserted pruning via EXPLAIN, which reports what the read planner selects for a SELECT — a different layer from what a mutation is registered against. That is precisely the gap this ticket exists to close, so the old coverage passed while every delete still rewrote every part. Rewritten to assert on system.part_log MutatePart events. Separately, nonV7IdDisablesPruning was asserting against a retired regex that the new SQL shape can never match, making it vacuously true; migrated to the same doesNotContain("IN PARTITION") check its siblings use.

Not run, with reason — acceptance criteria 3, 4, and 7 are unverified:

  • 3 (TracesDeleted cascade fires again once the statement stops erroring) and 4 (deletion_events_local receives its row) need the full cascade path running, not reachable from these suites.
  • 7 (clock-skewed trace-delete e2e specs) needs the e2e suite.

These are the criteria that confirm the user-visible consequence of the fix, as distinct from the mutation-level mechanism proven above. I'd treat them as a merge gate rather than a follow-up.

Known tradeoffs — deliberately not fixed here

Both surfaced repeatedly in review; both are documented at their call sites rather than addressed, because fixing either expands beyond this ticket's stated scope:

  1. TraceService's delete→cascade coupling is still all-or-nothing. A batch can now complete several partitions' statements before one errors, so more of a batch's rows can be synchronously, confirmedly deleted without the cascade firing than before this change. Deletes are idempotent, and the ticket is explicit: "removes the condition that trips it; does not restructure the coupling." Scoping each mutation so it completes well inside the timeout is what makes hitting this much rarer.
  2. The IN PARTITION gate is a name check, not a live schema read. A tracesDistributedWrapEnabled() flip that precedes the EXCHANGE would turn deletes into Code 248 rather than degrading to unpruned-but-correct. This is the same operational precondition tracesMutationTable() already relies on for every other mutation in the class (a premature flip breaks reads and inserts too, just differently) — made explicit rather than newly introduced.

Documentation

No documentation update required — internal query-shape change with no user-facing API, schema, or behavioural surface. Rationale that would otherwise live in docs is captured in the TraceDAO and WeeklyPartitions Javadoc, including why IN PARTITION is interpolated rather than bound, why DELETE FROM is kept over a hand-written ALTER … UPDATE, and why the far-future double-partition case is safe.

… partitions

A ClickHouse mutation is registered against every active part of the target
table before its WHERE clause is even considered - the existing partition
predicate (OPIK_6901) prunes which rows a matched part rewrites, not which
parts the mutation visits at all. Post-cutover traces_local has ~1,900
partitions / ~3,650 parts, so an ordinary delete matching a handful of rows
was registered against all of them, at ~19ms/part fixed overhead. That is
what pushed deletes past max_execution_time (Code 159 TIMEOUT_EXCEEDED),
where the statement errors and skips the TracesDeleted cascade and the
deletion-events bridge write even though the mutation completes and the
rows are in fact deleted.

Scope each statement to the partitions its own batch resolves to via
IN PARTITION, derived by WeeklyPartitions#groupByPartition (new; groups
ids by partition rather than naming their union - IN PARTITION accepts
exactly one partition per statement). One statement per partition,
falling back to today's unbounded form whenever the batch contains an id
whose partition cannot be derived exactly, or whenever the mutation's
target table is not actually partitioned (the legacy pre-wrap traces has
no partition key at all, so IN PARTITION against it is a ClickHouse
syntax error - Code 248 INVALID_PARTITION_VALUE - not a no-op). The
routing decision stays in exactly one place: deleteBatch calls
tracesMutationTable() itself, resolved once and reused across every
partition-scoped statement in the batch, never the wrap flag directly
(TraceMutationRoutingArchTest enforces this).

Both forms share one SQL template, DELETE_BY_PROJECT_ID_TRACE_ID_PAIRS,
with an <if(partition)>IN PARTITION <partition><endif> conditional -
scoped and unbounded no longer duplicate their WHERE/SETTINGS body across
two near-identical constants, matching the pre-diff code's own pattern
for its now-removed <if(partitions)> fragment. executeDelete lost its
sqlTemplate parameter along with the second constant, since there is now
only one to pass.

WeeklyPartitions#of, the flat-union predecessor this class shipped with,
is removed - groupByPartition is its only production caller's need, and
of() had none left once TraceDAO switched over. Its unique boundary-case
coverage (ceiling/floor ids, largest UUIDv7 timestamp) was migrated to
groupByPartition rather than dropped; the rest was redundant with
groupByPartition's own tests using the same pinned ids. Every stale
{@link WeeklyPartitions#of} javadoc reference left over from the rename
now points at #groupByPartition.

TracesLegacyTablePruningMutationTest's nonV7IdDisablesPruning assertion
was still matching against the retired row-level predicate regex - a
tautology that could never fail regardless of correctness, since the new
SQL shape can't contain it either way. Migrated to the same
doesNotContain("IN PARTITION") check its two sibling assertions in the
same file already use, and the now-fully-dead PARTITION_PREDICATE /
EMITTED_IN_CLAUSE / HONEST_WEEK / LEGACY_WEEK / boundPartitionsOf this
left behind were removed with it.

Two known, accepted tradeoffs, documented at their call sites rather than
addressed here since both are out of this ticket's stated scope:
- TraceService's delete->cascade coupling (TracesDeleted publish +
  deletion-events capture) is still all-or-nothing over a batch that can
  now complete several partitions' statements before one errors, so more
  of a batch's rows can be synchronously, confirmedly deleted without the
  cascade firing than before. Deletes are idempotent and this is the
  ticket's own explicit boundary ("removes the condition that trips it;
  does not restructure the coupling").
- The IN PARTITION gate is a name check (tracesMutationTable() ==
  traces_local), not a runtime read of the table's actual partition key -
  an operational precondition the class already relies on for every other
  mutation, made explicit rather than newly introduced.

Verified locally against real ClickHouse via testcontainers:
- WeeklyPartitionsTest: 17/17
- TracesPartitionPruningMutationTest: 8/8, rewritten to assert on
  system.part_log MutatePart events rather than EXPLAIN (which reports
  read-planner pruning, a different layer from what a mutation actually
  visits - the gap the old coverage fell into)
- TracesLegacyTablePruningMutationTest: 3/3, updated for the corrected
  pre-wrap behavior
- TraceMutationRoutingArchTest: 3/3 - the routing decision is still made
  in exactly one place

Not yet exercised: the TracesDeleted cascade and deletion_events_local
write firing again once the statement stops erroring, and the e2e specs -
none reachable without the full cascade path or the e2e suite running.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@thiagohora
thiagohora requested a review from a team as a code owner September 3, 2026 15:44
@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 Sep 3, 2026
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

⏱️ pre-commit per-hook timing

Hook Description Result Duration
☕ spotless — java backend Format Java code 4.91s
🛡️ semgrep — java backend sql Block SQL injection-prone string formatting 1.91s
Total (2 ran) 6.82s
⏭️ 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

Copy link
Copy Markdown
Collaborator

Worth a test, but not testable yet.

The IN PARTITION scoping only runs when tracesMutationTable() resolves to traces_local — i.e. ANALYTICS_DB_DATA_MODEL_TRACES_DISTRIBUTED_WRAP_ENABLED=true over the post-cutover partitioned table. Our e2e estate is the OSS docker-compose default (flag false, MergeTree traces), so deleteBatch always takes the grouped.isEmpty() fallback there and no Playwright spec can reach the new path; TracesPartitionPruningMutationTest already covers it where it is reachable, hand-authoring the topology to do so. Recording this as deferred rather than 'no test needed' because post-cutover a mis-derived partition deletes nothing and still returns 2xx — a silent skip is exactly what an e2e delete spec should catch, and nothing will re-triage this when the wrap is flipped in a later PR. Separately, the OSS-reachable half is already guarded: trace-explore/trace-delete.spec.ts.

Testable once (platform): An estate where traces is the Distributed wrapper over a weekly-partitioned traces_local AND ANALYTICS_DB_DATA_MODEL_TRACES_DISTRIBUTED_WRAP_ENABLED=true. Neither half alone is enough: docker-compose defaults the flag to false, and ClickHouseTracesTopologyHealthCheck fails readiness if the flag is flipped without the cutover EXCHANGE having run.

What a test would assert: Bulk delete on the Logs page and DELETE /v1/private/traces/delete. Post-cutover a wrong partition derivation is silent, not loud: the statement names a partition the row is not in, matches nothing, and the request still succeeds — the trace reappears on reload. The axis a test must vary is the deployment topology (unpartitioned traces vs the Distributed wrap over weekly-partitioned traces_local), not the request or the caller; and the second axis is the id's embedded UUIDv7 week, since the partition is derived from it — a present-day id resolves to one partition, an id past the DateTime 32-bit modulus to two, and a non-UUIDv7 or post-ceiling id to the unbounded fallback.

Recorded rather than dropped, so this resurfaces when the gate opens — a flag is usually flipped by a PR that touches no product code and so is never triaged on its own.

Also already tested. The behaviour this PR keeps on the default OSS topology — the unbounded fallback, byte-identical to pre-OPIK-8230 — is covered by tests_end_to_end/e2e/tests/trace-explore/trace-delete.spec.ts, which bulk-deletes two of three traces from the Logs table and re-deletes via the REST API, asserting in both cases that the targeted traces are gone from the UI and return null from GET /traces/{id} while the survivor remains. That would fail loudly if the TRACES_LOCAL_TABLE gate in deleteBatch were wrong and IN PARTITION reached the unpartitioned traces (Code 248), which is the trap your own Javadoc calls out. It does not distinguish the scoped path from the fallback — on this estate only the fallback ever runs — so it is regression cover for the refactor, not for the fix.

also touches Backend (Java API / internal)

Run

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

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Backend Tests - Integration Group 9

 46 files   -  1   46 suites   - 1   11m 5s ⏱️ - 1m 13s
547 tests  -  8  543 ✅  -  9  3 💤 ±0  1 ❌ +1 
544 runs   - 11  540 ✅  - 12  3 💤 ±0  1 ❌ +1 

For more details on these failures, see this check.

Results for commit cf0ebe7. ± Comparison against base commit 867d65e.

This pull request removes 18 and adds 10 tests. Note that renamed tests count towards both.
com.comet.opik.api.resources.v1.priv.OllieStateResourceTest ‑ deleteSucceedsWhenNoState
com.comet.opik.api.resources.v1.priv.OllieStateResourceTest ‑ downloadReturns404WhenNoState
com.comet.opik.api.resources.v1.priv.OllieStateResourceTest ‑ uploadDownloadDeleteOllieState
com.comet.opik.api.resources.v1.priv.OllieStateResourceTest ‑ uploadRejectsNonGzipData
com.comet.opik.api.resources.v1.priv.OllieStateResourceTest ‑ uploadReplacesExistingState
com.comet.opik.api.resources.v1.priv.ReportsResourceTest$Preferences ‑ getPreference__noPreference__returnsEmpty
com.comet.opik.api.resources.v1.priv.ReportsResourceTest$Preferences ‑ updatePreference__firstUpdate__createsPreference
com.comet.opik.api.resources.v1.priv.ReportsResourceTest$Preferences ‑ updatePreference__partialUpdate__preservesCustomPrompt
com.comet.opik.api.resources.v1.priv.ReportsResourceTest$Preferences ‑ updatePreference__partialUpdate__preservesScheduleTime
com.comet.opik.api.resources.v1.priv.ReportsResourceTest$Reports ‑ completeReport__completedWithReason__ignoresReason
…
com.comet.opik.api.resources.v1.priv.AttachmentResourceMinIOTest ‑ deleteTraceDeletesTraceAndSpanAttachments(Consumer)[1]
com.comet.opik.api.resources.v1.priv.AttachmentResourceMinIOTest ‑ deleteTraceDeletesTraceAndSpanAttachments(Consumer)[2]
com.comet.opik.api.resources.v1.priv.AttachmentResourceMinIOTest ‑ deleteTraceScopedToProjectLeavesOtherProjectsAttachmentUntouched
com.comet.opik.api.resources.v1.priv.AttachmentResourceMinIOTest ‑ invalidBaseUrlFormatReturnsError
com.comet.opik.api.resources.v1.priv.AttachmentResourceMinIOTest ‑ uploadAttachmentWithMultiPartPresignUrl
com.comet.opik.domain.mcpoauth.McpOAuthScrubServiceTest ‑ daoDeletesRevokedTokensOnlyPastGrace
com.comet.opik.domain.mcpoauth.McpOAuthScrubServiceTest ‑ daoKeepsUnexpiredCodes
com.comet.opik.domain.mcpoauth.McpOAuthScrubServiceTest ‑ scrubsExpiredArtifactsFromOAuthFlow
com.comet.opik.infrastructure.auth.AuthModuleCache2E2Test ‑ testAuthCache__whenApiKeyAndWorkspaceAreCached__thenUseTheCacheUntilTTLExpire
com.comet.opik.infrastructure.http.cors.CorsDisabledE2ETest ‑ testCorsDisabled

Comment on lines 835 to +836
delete(Set.of(Pair.of(projectId, ids.getFirst())));
var bounded = partsSelectedBy(lastTraceDeleteSql(ids.getFirst()))
.orElseThrow(() -> new AssertionError(
"EXPLAIN reported no partition index for the bounded delete"));

// Unbounded: a non-v7 id in the batch, so no predicate at all. Its partner is a different era, so the query_log
// lookup finds this statement rather than the one above.
var partner = ids.get(1);
delete(Set.of(Pair.of(projectId, partner), Pair.of(projectId, NON_V7_ID)));
var unbounded = partsSelectedBy(lastTraceDeleteSql(partner));

assertThat(bounded.selected())
.as("the bounded delete selects fewer parts than the table holds: %s", bounded)
.isLessThan(bounded.total());
// Shown to discriminate, or it proves nothing - the same trap as `.contains(partition)` and
// `doesNotContain("toDayOfWeek")`. The fallback must not prune: either the planner reports no partition index at
// all, because nothing filters on the key, or it reports every part still selected.
assertThat(unbounded.map(parts -> parts.selected() == parts.total()).orElse(true))
.as("the fallback prunes nothing: %s", unbounded)
.isTrue();
var bounded = mutatePartActivitySince(table, boundedSince);

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.

Pruning test is timing-flaky

bounded.parts()/partitions() and the unbounded activity snapshot are read immediately after DELETE FROM, but mutatePartActivitySince only runs SYSTEM FLUSH LOGS, which doesn't wait for MutatePart events, so snapshots can be incomplete and footprint assertions flaky. Could we wait for the target table's mutations to settle after each delete before taking each snapshot, while retaining since before submission?

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/TracesPartitionPruningMutationTest.java`
around lines 835-836 and 848-849, update `pruningReachesThePlannerAndTheFallbackDoesNot`
so it does not snapshot `system.part_log` immediately after asynchronous deletes. Keep
each `since` timestamp before submitting its delete, then call
`waitForMutationsToSettle(table)` after each delete and before
`mutatePartActivitySince(...)`, ensuring the bounded and unbounded activity measurements
are complete and deterministic.

Comment on lines 3685 to +3688
return makeMonoContextAware((userName, workspaceId) -> Flux
.fromIterable(Lists.partition(List.copyOf(projectIdTraceIdPairs), ANALYTICS_DELETE_BATCH_SIZE))
.concatMap(batch -> {
var template = getSTWithLogComment(DELETE_BY_PROJECT_ID_TRACE_ID_PAIRS, "delete_traces",
workspaceId,
userName, "pairs_size=%s".formatted(batch.size()));
selectTracesMutationTable(template);

var projectIds = batch.stream().map(pair -> pair.getLeft().toString()).toArray(String[]::new);
var traceIds = batch.stream().map(pair -> pair.getRight().toString()).toArray(String[]::new);

// Prune to the batch's own partitions when every id in the batch allows it; otherwise emit the
// unbounded form. Needs no schema flag: WeeklyPartitions derives a value per id_at type the
// mutation may meet, so the set is correct on the legacy traces and on the partitioned successor.
var partitions = WeeklyPartitions.of(batch.stream().map(Pair::getRight).toList());
// Flag only, exactly like distributed_wrap: the values reach ClickHouse via the bind below,
// never through the template, so the rendered SQL is constant regardless of batch contents.
partitions.ifPresent(_ -> template.add("partitions", true));

var statement = connection.createStatement(template.render())
.bind("workspace_id", workspaceId)
.bind("project_ids", projectIds)
.bind("trace_ids", traceIds);
.concatMap(batch -> deleteBatch(batch, workspaceId, userName, connection))
.then());

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.

TraceDAO.delete can finish an earlier executeDelete before a later partition fails, so .block() surfaces the error while those deleted rows produce no TracesDeleted event or deferred deletion capture — should we track completed statements or add atomic recovery? TraceServiceImplTest only covers failures before any delete succeeds; should we add a distinct-partition partial-success case asserting the first row is deleted but no event or bridge rows are emitted?

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/main/java/com/comet/opik/domain/TraceDAO.java` around lines
3685-3688, refactor `TraceDAO.delete`/`deleteBatch` so a failure in a later partition
mutation cannot leave earlier successful deletions unrepresented to the cascade and
deferred deletion-event capture. Return or propagate an explicit outcome containing the
successfully deleted `(project_id, trace_id)` pairs, and update the relevant service
call path to capture/post events for those pairs even when the overall request fails;
alternatively implement a reliable recovery/retry path that completes the remaining
deletes before reporting failure. Then update or add a `TraceServiceImplTest` (or
equivalent integration test at the service/DAO seam) using IDs resolving to at least two
partitions, making the first delete succeed and the second fail, asserting that the
earlier row is deleted while no `TracesDeleted` event or bridge rows are emitted for the
failed batch.

Comment on lines 3685 to +3688
return makeMonoContextAware((userName, workspaceId) -> Flux
.fromIterable(Lists.partition(List.copyOf(projectIdTraceIdPairs), ANALYTICS_DELETE_BATCH_SIZE))
.concatMap(batch -> {
var template = getSTWithLogComment(DELETE_BY_PROJECT_ID_TRACE_ID_PAIRS, "delete_traces",
workspaceId,
userName, "pairs_size=%s".formatted(batch.size()));
selectTracesMutationTable(template);

var projectIds = batch.stream().map(pair -> pair.getLeft().toString()).toArray(String[]::new);
var traceIds = batch.stream().map(pair -> pair.getRight().toString()).toArray(String[]::new);

// Prune to the batch's own partitions when every id in the batch allows it; otherwise emit the
// unbounded form. Needs no schema flag: WeeklyPartitions derives a value per id_at type the
// mutation may meet, so the set is correct on the legacy traces and on the partitioned successor.
var partitions = WeeklyPartitions.of(batch.stream().map(Pair::getRight).toList());
// Flag only, exactly like distributed_wrap: the values reach ClickHouse via the bind below,
// never through the template, so the rendered SQL is constant regardless of batch contents.
partitions.ifPresent(_ -> template.add("partitions", true));

var statement = connection.createStatement(template.render())
.bind("workspace_id", workspaceId)
.bind("project_ids", projectIds)
.bind("trace_ids", traceIds);
.concatMap(batch -> deleteBatch(batch, workspaceId, userName, connection))
.then());

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.

Missing multi-partition cascade coverage

The new concatMap fan-out is only covered by direct TraceDAO tests, while existing DeletionEventTest batches stay within one partition, so multi-partition deletes aren't verified end-to-end to publish one TracesDeleted cascade and capture every deletion_events_local row — should we add a service-level integration test with distinct partitions and capture enabled?

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/main/java/com/comet/opik/domain/TraceDAO.java` around lines
3685-3688, add service-level integration coverage for the `delete` method's
multi-partition `concatMap` completion behavior, rather than testing only the DAO
directly. Extend the relevant `DeletionEventTest` to create traces with IDs belonging to
distinct weekly partitions, enable deletion-event capture, and invoke deletion through
the service/API path. Assert that completion publishes exactly one expected
`TracesDeleted` cascade and that `deletion_events_local` contains every corresponding
deletion event.

Comment on lines 3685 to +3688
return makeMonoContextAware((userName, workspaceId) -> Flux
.fromIterable(Lists.partition(List.copyOf(projectIdTraceIdPairs), ANALYTICS_DELETE_BATCH_SIZE))
.concatMap(batch -> {
var template = getSTWithLogComment(DELETE_BY_PROJECT_ID_TRACE_ID_PAIRS, "delete_traces",
workspaceId,
userName, "pairs_size=%s".formatted(batch.size()));
selectTracesMutationTable(template);

var projectIds = batch.stream().map(pair -> pair.getLeft().toString()).toArray(String[]::new);
var traceIds = batch.stream().map(pair -> pair.getRight().toString()).toArray(String[]::new);

// Prune to the batch's own partitions when every id in the batch allows it; otherwise emit the
// unbounded form. Needs no schema flag: WeeklyPartitions derives a value per id_at type the
// mutation may meet, so the set is correct on the legacy traces and on the partitioned successor.
var partitions = WeeklyPartitions.of(batch.stream().map(Pair::getRight).toList());
// Flag only, exactly like distributed_wrap: the values reach ClickHouse via the bind below,
// never through the template, so the rendered SQL is constant regardless of batch contents.
partitions.ifPresent(_ -> template.add("partitions", true));

var statement = connection.createStatement(template.render())
.bind("workspace_id", workspaceId)
.bind("project_ids", projectIds)
.bind("trace_ids", traceIds);
.concatMap(batch -> deleteBatch(batch, workspaceId, userName, connection))
.then());

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.

Repeated deletes lack throughput control

delete can submit hundreds or thousands of sequential ClickHouse mutations per POST /traces/delete request, so an authorized caller repeating maximum-size requests can overwhelm ClickHouse — should we add a delete-specific rate/usage limit or a bounded shared concurrency gate?

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
3685-3688, the `delete(Set<Pair<UUID, UUID>>...)` method can submit hundreds or
thousands of ClickHouse mutations per request without any rate, usage, or concurrency
guard. Add delete-specific admission control—such as a shared bounded
semaphore/concurrency gate and an appropriate rate or usage limit on the corresponding
`POST /traces/delete` endpoint—so repeated maximum-size requests cannot overload
ClickHouse. Preserve batching behavior, reject or backpressure requests when capacity is
exhausted, and add coverage for the limit.

Comment on lines +3730 to +3732
var grouped = TRACES_LOCAL_TABLE.equals(table)
? WeeklyPartitions.groupByPartition(batch.stream().map(Pair::getRight).toList())
: Optional.<Map<Long, Set<UUID>>>empty();

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.

Deletes fail on incompatible schemas

deleteBatch emits IN PARTITION <yyyyMMdd> whenever the resolved table is traces_local, but readiness accepts any MergeTree-family table without verifying migration 000114’s partition contract, so an unpartitioned or differently partitioned table can reject the mutation or match no rows — should we fall back to the unbounded mutation or validate the live partition contract first?

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/main/java/com/comet/opik/domain/TraceDAO.java around lines
3730-3732, fix the `deleteBatch` partition-scoping gate, which assumes that
`traces_local` always has migration 000114’s weekly partition key based only on its
name. Require validation of the live table’s exact partition key/expression and type
before emitting `IN PARTITION`; otherwise fall back to the unbounded delete mutation.
Update the topology readiness check and relevant tests so an incompatible schema cannot
report healthy and trigger this branch.

Comment on lines +3743 to 3749
return Flux.fromIterable(grouped.get().entrySet())
.concatMap(entry -> {
var partitionPairs = entry.getValue().stream()
.flatMap(id -> pairsById.get(id).stream())
.toList();
return executeDelete(table, partitionPairs, entry.getKey(), workspaceId, userName, connection);
})

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.

deleteBatch issues unbounded sequential executeDelete calls over grouped.entrySet(), so large batches can time out after only a prefix completes; retries then re-resolve ownership from live traces, leaving related rows and deletion-event bridge entries orphaned with no repair path. Should we bound or split the fan-out and durably track per-partition completion against the originally resolved set?

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
3743-3749 (and the surrounding `delete` flow at lines 3685-3688), address two related
issues in the `deleteBatch` partition-mutation loop: 1. Bound the fan-out:
`grouped.entrySet()` traversal issues unbounded sequential `executeDelete` calls — the
existing pair-size limit does not bound mutation/partition count, so a batch spanning
many weeks/partitions can time out mid-way. Add an explicit request-level fan-out
budget, validate the complete planned operation before executing any mutation, and
either reject with a clear failure or split into independently bounded operations while
preserving every pair and the existing unbounded fallback behavior. 2. Make partial
failure recoverable: if a later partition mutation fails after an earlier one succeeds,
retrying should not rely on re-resolving ownership from live traces, since successfully
deleted pairs disappear from that resolution. Preserve the complete originally resolved
`(project_id, trace_id)` set across retries, or add durable per-partition
completion/cascade tracking, so related rows and deletion-event bridge entries for
already-deleted pairs get cleaned up. Add or update tests covering: (a) a batch spread
across more partitions than the configured limit, verifying no partial prefix is
submitted, and (b) a later-partition failure and retry, verifying related rows and
deletion-event bridge entries are also removed.

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.

2 participants