-
[OPIK-7636] [BE] perf: dedup the feedback-scores trace_final CTE with argMax instead of FINAL (#7689)
发布于
2026-08-03 10:25:56 +00:00 - [OPIK-7636] [BE] perf: dedup trace-stats trace_final with argMax, gated on searchText
Replace FINAL with GROUP BY + argMax in the trace_final CTE of both
trace-stats templates. traces is a ReplacingMergeTree(last_updated_at)
ordered by (workspace_id, project_id, id), so grouping on that key and
taking each value-based predicate's verdict from the greatest
last_updated_at is exactly what FINAL + predicate computes.The rewrite is exact but not unconditionally cheaper: it trades a
streaming scan that filters row by row for a hash table holding one group
per trace in the key range, built in full before HAVING can discard
anything. It therefore renders only when search_text is present, on top
of the existing join-bearing scope guard. Measured in production, with
searchText it is ~2.8x cheaper in CPU and ~3.4x in peak memory on the
feedback-scores template, which re-evaluates this CTE from three scopes;
without it the aggregation state is pure overhead, up to ~6.3x more CPU
and ~85x more peak memory on a 26M-trace project.Non-key predicates move into HAVING argMax(...) so they are evaluated on
the latest version; leaving them row-level in WHERE would strand a group
on the latest surviving version. Per-version columns are aliased latest_*
and renamed by an enclosing SELECT, since a same-named alias nests argMax
inside argMax and ClickHouse rejects it with ILLEGAL_AGGREGATION.Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
- [OPIK-7636] [BE] fix: pick the latest trace version atomically with a tuple-valued argMax
Per-column argMax let row versions sharing the greatest last_updated_at
contribute different columns to the same output row, synthesising a row
that never existed — where FINAL always yields one whole physical row.
Wrapping the projection in a single argMax(tuple(...), last_updated_at)
and unpacking it in the enclosing SELECT picks one version atomically, so
a timestamp tie degrades to "which of the tied versions", exactly as under
FINAL, rather than "a mixture of them".Verified against production: result-identical to the per-column form
(132,936 groups, same hash) and to FINAL on the full rendered query, at
lower peak memory (226 vs 353 MiB — one aggregate state instead of seven).The tuple alias also shadows no column, so it removes the latest_* renaming
that existed only to keep HAVING from resolving against an aggregate alias
and failing with ILLEGAL_AGGREGATION.Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
- [OPIK-7636] [BE] docs: correct the tuple-argMax memory claim after re-measuring
The tuple form's memory advantage does not generalise. Re-measured on
production across the shapes the gate is built on: 226 vs 353 MiB over 133K
groups and 3.56 vs 4.11 GiB on the no-filter shape, but identical 9.43 GiB
over 26.4M groups, where the aggregate state is dominated by the grouped
values rather than per-aggregate overhead.The gate rationale is unchanged and re-verified against the tuple form: the
no-filter shape is still +51% memory and +24% CPU versus FINAL, and the
26.4M-group cheap-filter shape is still ~85x memory and ~6x CPU.Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
- [OPIK-7636] [BE] docs: record why no argMax tie-breaker is added
ReplacingMergeTree breaks an equal-ver tie by "most recently inserted row
wins", and insertion order is not a column, so no value-based tie-breaker
can reproduce it — adding one would deliberately pick a different row than
FINAL in exactly the tie case, turning an unobserved divergence into a
guaranteed one.Measured instead: over 20 tied versions inserted in scrambled order across
multiple parts, and in the adversarial case where the last-inserted row
sorts lowest by value, FINAL and argMax selected the same row on every run
at 8 threads. Also recorded why ties are near-unreachable: rows sharing a
sort key within one INSERT collapse at part write, separate batches take
distinct now64(6) values, and no duplicate row versions exist across the
four largest production projects.Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
- [OPIK-7636] [BE] docs: scope the argMax equivalence claim to unique latest versions
Stop claiming exact equivalence with FINAL across the board. For any key
whose latest last_updated_at is unique, argMax returns exactly the FINAL
row. For a key with tied latest versions it returns one of the tied rows,
and which one is not contractually the same row FINAL would pick, because
ClickHouse does not specify argMax's tie behaviour. That is an accepted,
bounded difference rather than exact equivalence.Both forms always return a whole row that was really stored, and the
difference can only surface on keys carrying byte-identical
last_updated_at. The measured agreement across tie scenarios is recorded
as observed behaviour, explicitly not as a guarantee.Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
- [OPIK-7636] [BE] docs: condense the canDedupByArgMax javadoc
The javadoc had grown into a benchmark log. Trimmed from 80 to 53 lines,
keeping the rules a maintainer needs — the two gate conditions, predicate
placement, why the projection is one tuple-valued argMax, the scope of the
equivalence, and the is_deleted cutover prerequisite — and dropping every
measurement figure and verification narrative. Measurements live in
OPIK-7636, which the javadoc now points at instead of restating.Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
- [OPIK-7636] [BE] refactor: address review — named tuple, rendered-SQL tests, helper cleanup
Named tuple instead of positional unpacking. Inserting a column into the
middle of the 7-element tuple shifts every later position and positional
access absorbs it silently; a wrong name is a parse error. Verified on
26.3.16.16: reordering the members made latest.1 return duration where
thread_id was expected, while latest.thread_id stayed correct. Result-
identical to the positional form on prod traces (140,317 groups, same hash),
and the ::Tuple(...) only attaches names to a type the expression already
has.Rendered-SQL tests. The guard suite only asserted the boolean; nothing
covered the SQL. Adds 6 cases: the traces/spans template drops FINAL, emits
GROUP BY/HAVING, unpacks by name, and renders balanced parentheses — the
wrapper's "FROM (" and its ")" come from separate template blocks — plus a
case pinning the deliberate asymmetry that the feedback-scores template
groups with no wrapper, so adding a projected column there fails loudly.Javadoc: record that filters is not vetoed even though TraceField.GUARDRAILS
renders the joined gagg alias into it, because FilterUtils always sets
guardrails_filters alongside and that is what vetoes — the two must stay in
sync. Also record that the <if(filters || search_text)> wrapper is
deliberately defensive rather than reachable.Tests: collapse three overloaded traceCount helpers into one and group both
helpers ahead of the tests, removing the overload-resolution footgun between
a search string and a single-element filter list.Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
- [OPIK-7636] [BE] test: drop the rendered-SQL assertions, keep coverage black-box
Reverts the SQL-string tests and the @VisibleForTesting widening of the two
stats templates. Asserting the rendered query text couples the suite to how
the SQL is written rather than what it returns; the integration tests already
cover the same failure modes by executing the query — unbalanced SQL from a
one-sided edit to the wrapper fails to parse, and a missing wrapper or a
mis-ordered projection shows up as wrong stats.Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
- [OPIK-7636] [BE] fix: keep filters index-visible via an id semi-join
Moving value-based predicates into HAVING argMax(...) put them out of reach
of the table's skip indexes. Measured on a 2.5M-trace project, a thread_id
equality filter plus searchText: FINAL prunes to 1 of 10,169 granules and
reads 4.08 MiB / 108 ms CPU; the argMax form read all 10,169 granules,
51.54 GiB / 22,416 ms — 12,900x bytes and 207x CPU. The searchText gate did
not prevent it, because that query has searchText.Filters are now also injected as id IN (SELECT id FROM traces WHERE
). The subquery is index-visible, and since it selects ids where any
version matches it is a superset of the correct answer, which the HAVING
narrows to the latest version.A column-level veto was implemented first and reverted: selectivity depends
on the filter's value, not its column. source IN ('sdk','unknown') prunes
nothing (those two values are 98% of rows) while source = 'playground' prunes
to zero granules, so a column list is wrong in both directions.Regression data, all result-identical:
- prunable shape (thread_id + searchText): 4.11 MiB / 48 ms, versus FINAL's
4.08 MiB / 108 ms — regression eliminated - production shape (source + searchText), 3 runs each: 55.60 GiB / 4.98 GiB /
62,277 ms with the semi-join versus 55.32 GiB / 4.33 GiB / 59,993 ms
without, and 60.86 GiB / 4.04 GiB / 69,221 ms for FINAL — the semi-join
costs ~4% CPU and ~15% memory and keeps the win
Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
- [OPIK-7636] [BE] refactor: narrow the argMax dedup to SELECT_FEEDBACK_SCORES_STATS
The two stats templates behave oppositely under the rewrite, and the reason
is the projection width. SELECT_FEEDBACK_SCORES_STATS projects only
id, project_id — both group keys — so its aggregate state is small and peak
memory improves. SELECT_TRACES_SPANS_STATS projects seven per-version columns,
so it holds a state per column per trace and memory regresses, worsening with
group count.Measured on production, all result-identical:
- feedback_scores + searchText, 132K groups: 10.33 GiB / 79,808 ms CPU with
FINAL versus 2.64 GiB / 25,250 ms — 3.91x memory, 3.16x CPU better - traces_spans + searchText, 2.51M groups: 4.04 GiB / 69,221 ms versus
4.98 GiB / 62,277 ms — 1.11x CPU better for +23% memory - traces_spans + searchText, 5.38M groups: 6.11 GiB / 168,234 ms versus
9.56 GiB / 138,057 ms — 1.22x CPU better for +56% memory
All 13 Code: 241 OOMs were on feedback_scores, where this takes peak memory
10.33 -> 2.64 GiB. traces_spans peaked at 34.7 GiB against a ~47 GiB ceiling,
so +56% there risks creating OOMs in a family that does not have them; its
~1.1-1.2x CPU is not worth that.SELECT_TRACES_SPANS_STATS is therefore reverted to byte-identical with
production, which also removes the wide-projection apparatus it needed: the
wrapper subquery, the named tuple, and the ILLEGAL_AGGREGATION aliasing
hazard. Only this template's gate call sites remain.Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
- [OPIK-7636] [BE] refactor: collapse the guard tests and trim the javadoc
Review follow-ups.
The seven guard test methods were the same assertion parameterised by
(slots, expected), so they are now one @CsvSource case with 18 rows —
same coverage, one method. Kept as a unit test rather than moved black-box
because the gate selects a SQL shape and both shapes return the same rows by
design, so it is not observable from query results; the behaviour the shapes
must share is already covered by the integration tests.Javadoc trimmed from 73 to 48 lines: the two gates, the guardrails coupling,
predicate placement, the semi-join, the scoped equivalence and the is_deleted
prerequisite, with measurements left to OPIK-7636 rather than restated.Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
Co-authored-by: Claude Opus 5 (1M context) noreply@anthropic.com
下载附件