Skip to content

Commit 9520bc5

Browse files
thiagohoraclaude
andauthored
[OPIK-8230] fix(traces): scope the delete mutation to the batch's own partitions (#8131)
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 the unbounded form when the batch cannot be derived exactly, or when the target table is not partitioned. That last condition has to be gated, not inferred from the table name. Unlike the id_at predicate it supersedes - MATERIALIZED on both topologies, so valid everywhere and merely unpruned on an unpartitioned table - IN PARTITION against a table with no PARTITION BY is a hard Code 248 INVALID_PARTITION_VALUE. A name check is wrong in both directions: post-EXCHANGE/pre-wrap `traces` is already the partitioned successor while the wrap flag is still false, so the check resolves to "traces", takes the fallback on every delete, and does nothing in exactly the state production is in today; and wrap-on without the EXCHANGE is a real supported topology (TracesDistributedWrapMutationTest) where traces_local is the legacy unpartitioned table and IN PARTITION fails outright. The gate reuses traceColumnsNonNullable rather than adding a flag. The EXCHANGE that replaces the Nullable(...) columns is the same one that puts the weekly-partitioned successor behind the name mutations target, so the two facts have only ever flipped together, and a second flag would have to be carried through the cutover runbook and tooling to track no independent state. Verified against all four topologies: legacy pre-cutover and wrap-without-EXCHANGE both read false and take the unbounded form; production today and the pruning suite both read true and scope. The name says only its first duty and is deliberately not renamed - the env var is exposed - so both it and the config comment now document the second. This leaves no deployment surface: no new config key, no Helm or docker-compose change, nothing to add to the runbook. config.yml and config-test.yml carry comment-only edits documenting the flag's second duty where an operator will actually look for it. Both forms share one SQL template with an <if(partition)> conditional, and WeeklyPartitions#of (the flat-union predecessor) is removed - it had no production callers once TraceDAO switched to groupByPartition. Its unique boundary-case coverage moved to groupByPartition rather than being dropped. A null project or trace id in the batch is now rejected at the DAO's entry point rather than surfacing as an NPE while stringifying the binds. The NPE is pre-existing - `pair.getRight().toString()` is unchanged by this diff - but the partitioned path makes it worse: WeeklyPartitions reads a null as "underivable", so the batch silently takes the unbounded fallback, and a caller's bug reports as the slow delete this change exists to remove. The check sits before the gate, so it holds whichever branch deleteBatch takes. Verified locally against real ClickHouse via testcontainers, 35/35: - WeeklyPartitionsTest 17, TracesPartitionPruningMutationTest 9, TracesDistributedWrapMutationTest 4, TracesLegacyTablePruningMutationTest 2, TraceMutationRoutingArchTest 3 - The pruning suite asserts on system.part_log MutatePart events rather than EXPLAIN, which reports read-planner pruning - a different layer from what a mutation is registered against, and the gap the previous coverage fell into. - Gate verified by negative control: forced off, the emitted SQL carries no IN PARTITION and 5 of the 9 pruning tests fail, so the gate is not vacuous. - Null precondition likewise: removed, the new test reproduces exactly the reported NPE rather than the IllegalArgumentException it asserts. - Log reads poll a fast-flushing container config instead of SYSTEM FLUSH LOGS, which pushed only what had already buffered and so raced the rows it was meant to reveal. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 9e8f970 commit 9520bc5

10 files changed

Lines changed: 712 additions & 563 deletions

File tree

apps/opik-backend/config.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,13 @@ databaseAnalyticsDataModel:
122122
# non-nullable columns so writes bind the sentinels (end_time->epoch, ttft->NaN) instead. Gates reads too, so
123123
# while false a legitimate epoch end time round-trips unchanged instead of reading as null.
124124
# Flip in lockstep with the cutover EXCHANGE.
125+
# Also gates partition-scoped trace deletes: that same EXCHANGE puts the weekly-partitioned successor behind the
126+
# name mutations target, so true additionally means a delete may scope itself with IN PARTITION instead of being
127+
# registered against every part of the table. The two facts have only ever flipped together, which is why this
128+
# gates both rather than there being a second flag. IN PARTITION against an unpartitioned table is a hard error
129+
# (code 248), not a lost optimisation, so setting this true ahead of the EXCHANGE breaks deletes - as it already
130+
# breaks writes, which bind sentinels a Nullable column has no use for. Off falls back to the unbounded delete:
131+
# correct, merely slower.
125132
traceColumnsNonNullable: ${ANALYTICS_DB_DATA_MODEL_TRACE_COLUMNS_NON_NULLABLE:-false}
126133
# Default: false
127134
# Description: Spans sibling of traceColumnsNonNullable. Leave false while the spans table still has Nullable

apps/opik-backend/src/main/java/com/comet/opik/domain/TraceDAO.java

Lines changed: 76 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1967,40 +1967,22 @@ ORDER BY (workspace_id, project_id, trace_id, id) DESC, last_updated_at DESC
19671967
""";
19681968

19691969
/**
1970-
* Deletes by the full {@code (workspace_id, project_id, id)} sort key, matching on {@code (project_id, id)} tuples
1971-
* so a single statement can span several projects (e.g. a reused id resolved to all its owning projects, or a
1972-
* cross-project batch) instead of one delete per project (OPIK-7483). Every deleted row carries its {@code
1973-
* project_id}, so no delete is ever project-less - also required once {@code traces} is a Distributed table
1974-
* (OPIK-7455).
1970+
* Deletes by the full {@code (workspace_id, project_id, id)} sort key, matching {@code (project_id, id)} tuples so
1971+
* one statement can span several projects (OPIK-7483). Pairs are bound as two arrays and zipped, so the query text
1972+
* is constant regardless of batch size.
19751973
* <p>
1976-
* {@code <if(partitions)>} adds the table's own weekly partition expression, bound as the set of partitions the
1977-
* batch's ids resolve to. It is emitted whenever every id in the batch is one whose partition can be derived
1978-
* exactly ({@link WeeklyPartitions#of}); otherwise the predicate is omitted and the statement is byte-identical to
1979-
* the previous unbounded form. That is what preserves the original guarantee — a row whose {@code id_at} cannot be
1980-
* trusted is still deleted, because no id in such a batch is used to derive a partition.
1974+
* {@code <if(partition)>} adds {@code IN PARTITION}, which scopes which <b>parts</b> the mutation is registered
1975+
* against — a {@code WHERE} clause cannot, since parts are selected before it is considered, which is why a delete
1976+
* of a few rows rewrote all ~3,650 parts and timed out. Omitted for the unbounded fallback.
19811977
* <p>
1982-
* No schema flag gates it. {@link WeeklyPartitions} derives a value per {@code id_at} type the mutation may meet
1983-
* — the legacy 32-bit {@code DateTime} of {@code traces} as well as the {@code DateTime64(0)} of the partitioned
1984-
* successor — so one rendered statement is correct on both sides of the cutover EXCHANGE, in either direction, with
1985-
* nothing to flip and nothing to revert on rollback.
1986-
* <p>
1987-
* Why it matters: a mutation selects parts at the <b>partition</b> stage, where the (workspace_id, project_id, id)
1988-
* predicate prunes nothing, so deleting a handful of rows rewrote every part of the table. Measured on prod-test
1989-
* (271.6 M rows, 3,928 parts): 12 ids rewrote <b>3,928 parts / 5.40 TiB</b>. With this predicate the same batch
1990-
* selects <b>5</b> parts. An {@code id_at} <em>range</em> is not a substitute: on a batch spanning 1996 and 2200 a
1991-
* range still selected 2,644 parts, where the exact set selected 4.
1992-
* <p>
1993-
* The pairs are bound (never inlined) as two positional string arrays and zipped back into {@code (project_id, id)}
1994-
* tuples with {@code arrayZip}, so the query text is constant regardless of batch size and no value reaches the SQL
1995-
* as a literal. {@code arrayZip} is a deterministic function, not a subquery - ClickHouse rejects subqueries in
1996-
* delete mutations. Callers batch to keep each array within the driver's reliable bind size ({@link
1997-
* com.comet.opik.infrastructure.FilterUtils#ANALYTICS_DELETE_BATCH_SIZE}).
1978+
* {@code <partition>} is interpolated, not bound: {@code IN PARTITION {p:UInt32}} is a ClickHouse syntax error. Safe
1979+
* because the value is always a {@code long} from {@link WeeklyPartitions}.
19981980
*/
19991981
private static final String DELETE_BY_PROJECT_ID_TRACE_ID_PAIRS = """
20001982
DELETE FROM <traces_mutation_table>
1983+
<if(partition)>IN PARTITION <partition><endif>
20011984
WHERE workspace_id = :workspace_id
20021985
AND (project_id, id) IN arrayZip(:project_ids, :trace_ids)
2003-
<if(partitions)>AND toYYYYMMDD(toDate32(id_at) - toIntervalDay(toDayOfWeek(id_at, 1))) IN :partitions<endif>
20041986
SETTINGS log_comment = '<log_comment>'
20051987
;
20061988
""";
@@ -2328,7 +2310,7 @@ ORDER BY (workspace_id, project_id, id) DESC, last_updated_at DESC
23282310
* {@code :min_id}'s week and inverted the window, so the fast pass matched nothing and every id fell through to
23292311
* the unbounded pass. It now resolves them.
23302312
* <p>
2331-
* The fallback remains load-bearing for what {@link com.comet.opik.utils.WeeklyPartitions#of} still cannot derive
2313+
* The fallback remains load-bearing for what {@link com.comet.opik.utils.WeeklyPartitions#groupByPartition} still cannot derive
23322314
* exactly: an id at or past the end of {@code DateTime64}'s range, where {@code id_at} saturates to
23332315
* {@code 2299-12-31} whatever the real week, so every such id collapses into one partition. Real data contains
23342316
* them, so the bounded query is never a delete's sole resolver.
@@ -3645,42 +3627,79 @@ private Flux<? extends Result> getDetailsById(UUID id, Connection connection) {
36453627
public Mono<Void> delete(Set<Pair<UUID, UUID>> projectIdTraceIdPairs, @NonNull Connection connection) {
36463628
Preconditions.checkArgument(CollectionUtils.isNotEmpty(projectIdTraceIdPairs),
36473629
"Argument 'projectIdTraceIdPairs' must not be empty");
3630+
// Checked here rather than where the ids are stringified, so it holds whichever branch deleteBatch takes: the
3631+
// partitioned path would otherwise read a null as "underivable", silently take the unbounded fallback, and
3632+
// only then NPE - reporting a caller's bug as the slow delete this class exists to avoid.
3633+
Preconditions.checkArgument(
3634+
projectIdTraceIdPairs.stream().noneMatch(pair -> pair.getLeft() == null || pair.getRight() == null),
3635+
"Argument 'projectIdTraceIdPairs' must not contain null ids");
36483636
log.info("Deleting traces by (project_id, id) pairs, count '{}'", projectIdTraceIdPairs.size());
36493637

36503638
return makeMonoContextAware((userName, workspaceId) -> Flux
36513639
.fromIterable(Lists.partition(List.copyOf(projectIdTraceIdPairs), ANALYTICS_DELETE_BATCH_SIZE))
3652-
.concatMap(batch -> {
3653-
var template = getSTWithLogComment(DELETE_BY_PROJECT_ID_TRACE_ID_PAIRS, "delete_traces",
3654-
workspaceId,
3655-
userName, "pairs_size=%s".formatted(batch.size()));
3656-
selectTracesMutationTable(template);
3657-
3658-
var projectIds = batch.stream().map(pair -> pair.getLeft().toString()).toArray(String[]::new);
3659-
var traceIds = batch.stream().map(pair -> pair.getRight().toString()).toArray(String[]::new);
3660-
3661-
// Prune to the batch's own partitions when every id in the batch allows it; otherwise emit the
3662-
// unbounded form. Needs no schema flag: WeeklyPartitions derives a value per id_at type the
3663-
// mutation may meet, so the set is correct on the legacy traces and on the partitioned successor.
3664-
var partitions = WeeklyPartitions.of(batch.stream().map(Pair::getRight).toList());
3665-
// Flag only, exactly like distributed_wrap: the values reach ClickHouse via the bind below,
3666-
// never through the template, so the rendered SQL is constant regardless of batch contents.
3667-
partitions.ifPresent(_ -> template.add("partitions", true));
3668-
3669-
var statement = connection.createStatement(template.render())
3670-
.bind("workspace_id", workspaceId)
3671-
.bind("project_ids", projectIds)
3672-
.bind("trace_ids", traceIds);
3640+
.concatMap(batch -> deleteBatch(batch, workspaceId, userName, connection))
3641+
.then());
3642+
}
36733643

3674-
if (partitions.isPresent()) {
3675-
statement = statement.bind("partitions", partitions.get().toArray(Long[]::new));
3676-
}
3644+
/**
3645+
* Deletes one batch: one {@code IN PARTITION} statement per partition its ids resolve to, or a single unbounded
3646+
* statement when they cannot all be derived, or when the target is not partitioned.
3647+
* <p>
3648+
* Sequential on purpose: bounded concurrency was measured and deferred (OPIK-8230).
3649+
*/
3650+
private Mono<Void> deleteBatch(List<Pair<UUID, UUID>> batch, String workspaceId, String userName,
3651+
Connection connection) {
3652+
// traceColumnsNonNullable doubles as "the mutation target is weekly-partitioned": the same cutover EXCHANGE
3653+
// drops the Nullable(...) columns and puts the partitioned successor behind the name mutations target, so one
3654+
// flag carries both facts. Deliberately not the wrap flag, which governs routing and is still false in the
3655+
// window between the EXCHANGE and the wrap - reading that one leaves production's deletes unpruned.
3656+
var grouped = traceColumnsNonNullable()
3657+
? WeeklyPartitions.groupByPartition(batch.stream().map(Pair::getRight).toList())
3658+
: Optional.<Map<Long, Set<UUID>>>empty();
3659+
3660+
if (grouped.isEmpty()) {
3661+
return executeDelete(batch, null, workspaceId, userName, connection);
3662+
}
36773663

3678-
var segment = startSegment("traces", "Clickhouse", "delete");
3679-
return Mono.from(statement.execute())
3680-
.doFinally(_ -> endSegment(segment))
3681-
.then();
3664+
// id -> its pairs, built once per batch rather than rescanning the whole batch once per partition: an id can
3665+
// map to more than one pair (the same trace id reused across projects, OPIK-7483), so this is a
3666+
// Collectors.groupingBy, not a plain lookup map.
3667+
var pairsById = batch.stream().collect(Collectors.groupingBy(Pair::getRight));
3668+
3669+
return Flux.fromIterable(grouped.get().entrySet())
3670+
.concatMap(entry -> {
3671+
var partitionPairs = entry.getValue().stream()
3672+
.flatMap(id -> pairsById.get(id).stream())
3673+
.toList();
3674+
return executeDelete(partitionPairs, entry.getKey(), workspaceId, userName, connection);
36823675
})
3683-
.then());
3676+
.then();
3677+
}
3678+
3679+
/**
3680+
* Renders and executes one delete statement — unbounded when {@code partition} is null, scoped to it otherwise.
3681+
*/
3682+
private Mono<Void> executeDelete(List<Pair<UUID, UUID>> pairs, Long partition,
3683+
String workspaceId, String userName, Connection connection) {
3684+
var template = getSTWithLogComment(DELETE_BY_PROJECT_ID_TRACE_ID_PAIRS, "delete_traces", workspaceId,
3685+
userName, "pairs_size=%s".formatted(pairs.size()));
3686+
selectTracesMutationTable(template);
3687+
if (partition != null) {
3688+
template.add("partition", partition);
3689+
}
3690+
3691+
var projectIds = pairs.stream().map(pair -> pair.getLeft().toString()).toArray(String[]::new);
3692+
var traceIds = pairs.stream().map(pair -> pair.getRight().toString()).toArray(String[]::new);
3693+
3694+
var statement = connection.createStatement(template.render())
3695+
.bind("workspace_id", workspaceId)
3696+
.bind("project_ids", projectIds)
3697+
.bind("trace_ids", traceIds);
3698+
3699+
var segment = startSegment("traces", "Clickhouse", "delete");
3700+
return Mono.from(statement.execute())
3701+
.doFinally(_ -> endSegment(segment))
3702+
.then();
36843703
}
36853704

36863705
/**

apps/opik-backend/src/main/java/com/comet/opik/domain/TraceService.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -537,7 +537,7 @@ public Mono<Void> delete(@NonNull Set<UUID> ids, UUID projectId) {
537537
* Resolves every owning project for each id: a bounded fast pass, then an unbounded pass over only the ids the
538538
* bounded one leaves unresolved. Returns id -> owning projects; ids absent from the result have no live row.
539539
* <p>
540-
* The bounded pass's week window can miss a row whose week {@link com.comet.opik.utils.WeeklyPartitions#of}
540+
* The bounded pass's week window can miss a row whose week {@link com.comet.opik.utils.WeeklyPartitions#groupByPartition}
541541
* cannot derive exactly — an id at or past the end of {@code DateTime64}'s range, where {@code id_at} saturates
542542
* to {@code 2299-12-31} whatever the real week — so the unbounded pass re-resolves the miss set and the bounded
543543
* query is never a delete's sole resolver. A far-future timestamp short of that ceiling is no longer such a case:
@@ -566,6 +566,12 @@ private Mono<Map<UUID, Set<UUID>>> resolveOwningProjects(Set<UUID> ids) {
566566
});
567567
}
568568

569+
/**
570+
* All-or-nothing over the batch: an error anywhere skips {@code TracesDeleted} and the deletion-events capture for
571+
* every pair, not just the failed one. OPIK-8230 widens the window — the DAO can now emit several statements per
572+
* batch, so earlier partitions' rows may already be gone. Deletes are idempotent; restructuring this coupling is
573+
* out of that ticket's scope.
574+
*/
569575
private Mono<Void> delete(Set<Pair<UUID, UUID>> projectIdTraceIdPairs, Connection connection) {
570576
return Mono.deferContextual(ctx -> {
571577
String workspaceId = ctx.get(RequestContext.WORKSPACE_ID);

apps/opik-backend/src/main/java/com/comet/opik/infrastructure/DatabaseAnalyticsDataModelConfig.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@
1515
* epoch end time round-trips unchanged rather than being read as {@code null}. Flip this in lockstep with the EXCHANGE
1616
* step of the cutover.</p>
1717
*
18+
* <p>It also gates partition-scoped deletes (OPIK-8230): the EXCHANGE that makes these columns non-nullable is the
19+
* same one that puts the weekly-partitioned successor behind the name mutations target, so this flag being
20+
* {@code true} is equally what says a delete may scope itself with {@code IN PARTITION}. One flag for two facts
21+
* because they have only ever flipped together; a second would have to be threaded through the cutover runbook and
22+
* tooling to track no independent state. The name says only the first duty and is deliberately not renamed - the env
23+
* var is exposed.</p>
24+
*
1825
* <p>{@code spanColumnsNonNullable}: the {@code spans} sibling of {@code traceColumnsNonNullable}, gating the same
1926
* sentinel wiring for {@code spans.end_time}→epoch and {@code spans.duration}/{@code spans.ttft}→{@code NaN}. Default
2027
* {@code false} while the {@code spans} table still has {@code Nullable(...)} columns; set {@code true} in lockstep with

0 commit comments

Comments
 (0)