Skip to content

[OPIK-6884] [BE] perf: full-scan audit Slice-1 remediations (R4, R6, R7, R8)#7404

Merged
thiagohora merged 8 commits into
mainfrom
thiagoh/OPIK-6884-full-scan-audit-slice-1-remediations
Jul 9, 2026
Merged

[OPIK-6884] [BE] perf: full-scan audit Slice-1 remediations (R4, R6, R7, R8)#7404
thiagohora merged 8 commits into
mainfrom
thiagoh/OPIK-6884-full-scan-audit-slice-1-remediations

Conversation

@thiagohora

@thiagohora thiagohora commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Details

Closes the self-contained ("mechanical") Bucket-1 remediations from the OPIK-6883 full-scan query audit, hardening traces/spans query paths ahead of the planned weekly time-partitioning + hot/cold tiering. This is one increment of the rolling OPIK-6884 story; the structural items (R1/R2/R3) remain as separate follow-ups and are not required for Slice-1 close.

  • R4 — traces/spans deletes (TraceService, TraceDAO): the delete cascade accepted a nullable project_id (delete-by-id passes null; a batch may span projects), so it dropped the project_id predicate and could no longer prune on the (workspace_id, project_id) sorting-key prefix. TraceService.delete now resolves each trace's owning project and deletes per project group with a concrete project_id, which flows through the existing TracesDeleted event to the span/feedback cascade unchanged (no event-contract change). Resolution uses a new partition-bounded lookup (getProjectIdsByTraceIdsBounded) that bounds toMonday(id_at) by the id set's own min/max — exact because id_at is MATERIALIZED UUIDv7ToDateTime(toUUID(id)) (migration 000091). Prod EXPLAIN confirms this prunes both the trace delete (58 granules) and, crucially, the span cascade (SELECT_SPAN_IDS_BY_TRACE_ID: 4,787 → 164 granules, ~29×, since project_id is the spans' 2nd PK column).
    • Deletion-events bridge (merged from main): the bridge captures the project_id on the delete path, so by-id / unscoped deletes now record the resolved project instead of nullTraceDeletionEventTest's two unscoped expectations were updated accordingly (needs a nod from the bridge owner). This relies on the invariant that a span always shares its trace's project; two TracesResourceTest cascade tests that created a span in a different project than its trace were corrected to use the trace's project.
  • R7 — retention velocity estimate (SpanDAO.ESTIMATE_VELOCITY_FOR_RETENTION): added a lower bound (trace_id >= :lower_bound) set to the catch-up service-start cursor, so the estimate scans [service_start, cutoff) instead of all pre-cutoff history. Makes the primary estimate path consistent with the scouting fallback, which already floors at the service-start date.
  • R6 — experiment project mapping (ExperimentDAO.COMPUTE_EXPERIMENT_PROJECT_MAPPING): the query joined experiment_items to traces with only a workspace_id bound on traces, so the ConcurrentHashJoin read the entire workspace trace slice into the hash table. Added AND t.id IN (SELECT trace_id FROM experiment_items WHERE workspace_id = :workspace_id), which gives traces an id primary-key condition (the set is implied by the join ei.trace_id = t.id, so the mapping result is unchanged) and bounds the read to the referenced trace ids. Verified on prod (ClickHouse 25.8.16 Altinity, large workspace) with EXPLAIN indexes=1 / EXPLAIN ESTIMATE: the traces read drops from ~28M rows / 97,997 granules (over the read cap) to ~1.25M rows / 1,896 granules (~52× fewer granules). Note: the audit's original R6 suggestion (an explicit t.workspace_id predicate) is a no-op on CH 25.8 — the analyzer already propagates that constant across the join — so the real lever was bounding the trace-id scan, not the tenancy filter.
  • R8 — dead code (FeedbackScoreDAO): removed the unreachable DELETE_SPANS_CASCADE_FEEDBACK_SCORE query and its only (uncalled) user, which also carried the degraded null-project_id filter.

Note: the audit's original R5 ("short-circuit empty target-project set to an empty result") was found to be a correctness bug (would drop legitimate rows and break projectDeleted filtering) and has been retracted/reclassified to R10 in OPIK-6883.

Change checklist

  • User facing
  • Documentation update

Issues

  • OPIK-6884 (partial — rolling story; structural R1/R2/R3 tracked separately)

AI-WATERMARK

AI-WATERMARK: yes

  • Tools: Claude Code
  • Model(s): Claude Opus 4.8
  • Scope: Codebase analysis, query/service edits, tests, prod EXPLAIN validation, and PR description for the remediations above.
  • Human verification: Author reviewed each change and the audit reclassification; all cited tests were run locally and the R6 no-op was confirmed via prod EXPLAIN before reverting.

Testing

Local, offline (-o), backend Testcontainers (real MySQL/ClickHouse):

  • mvn spotless:check — clean
  • mvn test -Dtest='TracesResourceTest$DeleteTrace,TracesResourceTest$DeleteTraces' — R4 single-trace, batch, and cascade; includes a new deleteTracesAcrossProjectsWithoutProjectId test covering the cross-project grouping path (11 tests, pass)
  • mvn test -Dtest=RetentionEstimationServiceTest — R7 unit path with updated mocks (6 tests, pass)
  • mvn test -Dtest='RetentionPolicyServiceTest$CatchUpJob' — R7 bounded query against real ClickHouse; small-workspace catch-up still deletes old data (2 tests, pass)
  • mvn test -Dtest=ExperimentProjectMigrationJobTest — R6: mapping result unchanged with the added IN predicate (real ClickHouse, 1 test, pass)

Merged origin/main (24 commits) to run against the same code as CI; the deletion-events bridge tests + the two TracesResourceTest cascade tests now pass locally alongside the delete suites.

Prod validation (read-only agentro, plan-only EXPLAIN — no execution/scan; CH 25.8.16, large workspace):

  • R6 COMPUTE_EXPERIMENT_PROJECT_MAPPING: the t.workspace_id predicate the audit suggested is a no-op (analyzer already propagates it); the t.id IN (...) predicate bounds the traces read from ~28M rows / 97,997 granules to ~1.25M rows / 1,896 granules (~52×).
  • R4 span cascade SELECT_SPAN_IDS_BY_TRACE_ID: resolved project_id → 4,787 → 164 granules (~29×); trace DELETE_BY_ID → 58 granules; the bounded project lookup prunes via id IN today and via the toMonday(id_at) range once traces are partitioned.
  • R7 ESTIMATE_VELOCITY_FOR_RETENTION: the added trace_id >= :lower_bound is applied as a trace_id PK condition, bounding the scan to [service_start, cutoff).

Not run: full repo regression suite (scoped to the affected areas). Structural items R1/R2/R3 are out of scope for this PR.

Documentation

No user-facing or SDK changes; internal query-path hardening only.

…o workspace and drop dead span-cascade delete

Closes Bucket-1 rows 17 (R6) and 19 (R8) of the OPIK-6883 full-scan audit:

- ExperimentDAO.COMPUTE_EXPERIMENT_PROJECT_MAPPING: add an explicit
  't.workspace_id = :workspace_id' predicate. The traces side previously
  carried tenancy only through the join key (ei.workspace_id = t.workspace_id),
  which does not let ClickHouse prune the traces table by its sorting-key
  prefix. The added predicate is provably equivalent given the join condition
  but restores primary-key pruning ahead of traces hot/cold tiering.

- FeedbackScoreDAO: remove the dead DELETE_SPANS_CASCADE_FEEDBACK_SCORE query
  and its only user cascadeSpanDelete (zero callers). It also carried the
  degraded 'project_id dropped when null' filter, so removing it clears an
  audit finding outright.
…n (workspace_id, project_id)

Closes Bucket-1 row 14 (R4) of the OPIK-6883 full-scan audit.

The trace delete paths accept a nullable project_id (delete-by-id passes null; a
batch may span projects), so the delete and its async span/feedback cascade
dropped the project_id predicate and could no longer prune on the
(workspace_id, project_id) sorting-key prefix - a workspace-wide scan once
traces are tiered.

TraceService.delete now, when no project is provided, resolves each trace's
owning project and deletes per project group with a concrete project_id. The
project flows through the existing TracesDeleted event to the span/feedback
cascade unchanged, so no event-contract or downstream-service change is needed.
Trace ids with no resolvable project (row already gone) fall back to a
workspace-scoped delete to still clean up orphan child rows.

The resolution itself is tiering-safe: getProjectIdsByTraceIdsBounded mirrors
SELECT_PROJECT_ID_FROM_TRACE, bounding toMonday(id_at) by the id set's own
min/max. id_at is MATERIALIZED as UUIDv7ToDateTime(toUUID(id)) (migration
000091), so the bound is exact (never drops a valid id) yet keeps the lookup on
the ids' own weekly partitions instead of scanning all cold history.

Adds a cross-project delete test covering the per-project grouping path.
…up service-start date

Closes Bucket-1 row 17 (R7) of the OPIK-6883 full-scan audit.

ESTIMATE_VELOCITY_FOR_RETENTION was bounded above (trace_id < cutoff) but had no
lower bound, so it aggregated over all pre-cutoff history - an unbounded scan
that pulls the whole cold tier once spans are tiered.

The retention catch-up already treats the service-start date as its floor: the
scouting fallback (scoutFirstDataCursor) scans month-by-month starting at the
service-start cursor and never looks earlier. Only the primary estimate path was
inconsistent, scanning back to the actual oldest span. Passing the service-start
cursor as a lower bound (trace_id >= :lower_bound, mirroring
DELETE_FOR_RETENTION) makes the two paths consistent and keeps the scan within
[service_start, cutoff) instead of all history.

Validated against real ClickHouse via RetentionPolicyServiceTest$CatchUpJob
(bounded query executes and small-workspace catch-up still deletes old data).
@thiagohora thiagohora requested a review from a team as a code owner July 8, 2026 15:15
@github-actions github-actions Bot added java Pull requests that update Java code Backend tests Including test files, or tests related like configuration. labels Jul 8, 2026
@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

⏱️ pre-commit per-hook timing

Hook Description Result Duration
☕ spotless — java backend Format Java code 6.23s
Total (1 ran) 6.23s
⏭️ 40 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 ⏭️
🌐 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 ⏭️

Comment thread apps/opik-backend/src/main/java/com/comet/opik/domain/TraceDAO.java
@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Backend Tests - Integration Group 14

614 tests  ±0   614 ✅ ±0   9m 25s ⏱️ -7s
 17 suites ±0     0 💤 ±0 
 17 files   ±0     0 ❌ ±0 

Results for commit aa0538c. ± Comparison against base commit 6a39082.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Backend Tests - Integration Group 3

385 tests  +1   385 ✅ +1   10m 0s ⏱️ +13s
 34 suites ±0     0 💤 ±0 
 34 files   ±0     0 ❌ ±0 

Results for commit aa0538c. ± Comparison against base commit 6a39082.

♻️ This comment has been updated with latest results.

…ping traces scan (R6)

Verified on prod (ClickHouse 25.8.16 Altinity) with EXPLAIN indexes=1 on the full
COMPUTE_EXPERIMENT_PROJECT_MAPPING for a large workspace: the analyzer already
propagates the workspace_id constant across the join equality
(ei.workspace_id = t.workspace_id AND ei.workspace_id = const) onto the traces
primary key. The traces read prunes to the same 97,992 granules with or without
an explicit t.workspace_id predicate; the added predicate only appears as a
duplicated 'workspace_id in [...]' condition and changes nothing.

The audit's R6 premise predates the 25.8 upgrade. Reverting the redundant
predicate; R8 (dead DELETE_SPANS_CASCADE_FEEDBACK_SCORE removal) stays.
@thiagohora thiagohora changed the title [OPIK-6884] [BE] perf: full-scan audit Slice-1 remediations (R4, R6, R7, R8) [OPIK-6884] [BE] perf: full-scan audit Slice-1 remediations (R4, R7, R8) Jul 8, 2026
…ace ids

COMPUTE_EXPERIMENT_PROJECT_MAPPING joined experiment_items to traces with only a
workspace_id bound on traces, so the ConcurrentHashJoin read the entire workspace
trace slice into the hash table. Adding
'AND t.id IN (SELECT trace_id FROM experiment_items WHERE workspace_id = :workspace_id)'
gives traces an 'id' primary-key condition (the set is implied by the join
ei.trace_id = t.id, so the result is unchanged) and bounds the read to the
referenced trace ids.

This is the effective replacement for the reverted R6 (row 16): the workspace_id
predicate the audit suggested is already applied by the CH 25.8 analyzer, whereas
this bounds the actual cost - the unpruned trace-id scan.

Validated by ExperimentProjectMigrationJobTest (mapping unchanged).
@thiagohora thiagohora changed the title [OPIK-6884] [BE] perf: full-scan audit Slice-1 remediations (R4, R7, R8) [OPIK-6884] [BE] perf: full-scan audit Slice-1 remediations (R4, R6, R7, R8) Jul 8, 2026
…ss review

Merged main brought the deletion-events bridge, whose tests exercise the same
delete path R4 changed. Fixes after merge:

- TraceDeletionEventTest: by-id / unscoped deletes now resolve the trace's owning
  project (R4), so the bridge captures the resolved project_id instead of null;
  update the two expectations accordingly.
- TracesResourceTest (TraceComment cascade): the span was created with a random
  project, distinct from its trace. A span always shares its trace's project, so
  create it in the trace's project - otherwise the (now project-scoped) delete
  cascade can't reach it. Fixes the two ConditionTimeout errors.

Review feedback (baz-reviewer):
- TraceDAO: extract collectTraceIdToProjectId() shared by getProjectIdsByTraceIds
  and getProjectIdsByTraceIdsBounded (dedup the reactive pipeline).
- TraceService: move the count to the end of the resolve-projects log message.
@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Python SDK E2E Tests Results (Python 3.14)

286 tests  ±0   283 ✅ ±0   5m 17s ⏱️ -1s
  1 suites ±0     3 💤 ±0 
  1 files   ±0     0 ❌ ±0 

Results for commit aa0538c. ± Comparison against base commit 6a39082.

♻️ This comment has been updated with latest results.

andrescrz
andrescrz previously approved these changes Jul 9, 2026

@andrescrz andrescrz left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Just a few nits, but LGTM.

Comment thread apps/opik-backend/src/main/java/com/comet/opik/domain/TraceDAO.java Outdated
Comment thread apps/opik-backend/src/main/java/com/comet/opik/domain/TraceDAO.java Outdated
Comment thread apps/opik-backend/src/main/java/com/comet/opik/domain/TraceDAO.java
- Convert the SELECT_PROJECT_IDS_BY_TRACE_IDS_BOUNDED explanatory comment to javadoc.
- Drop @nonnull on getProjectIdsByTraceIdsBounded(Set) — emptiness/null is already
  covered by the checkArgument(CollectionUtils.isNotEmpty(...)) guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@thiagohora thiagohora merged commit cf7015e into main Jul 9, 2026
70 checks passed
@thiagohora thiagohora deleted the thiagoh/OPIK-6884-full-scan-audit-slice-1-remediations branch July 9, 2026 11:23
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 tests Including test files, or tests related like configuration.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants