[OPIK-7396] [BE] fix: tolerate >1 emitted row in TraceDAO.findById (get-by-id 500)#7532
Conversation
GET /v1/private/traces/{id} 500s with IndexOutOfBoundsException ("Source emitted
more than one item") when the assembled get-by-id query fans out to >1 row for the
id. findById reduced with singleOrEmpty(), which throws on >1. Use next() so the
first row is returned and the empty case still maps to 404.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
⏱️ pre-commit per-hook timing
⏭️ 40 skipped (no matching files changed)
|
Replace next() with collectList()+first so we can emit a WARN (trace id + row count) when the get-by-id query fans out to more than one row, keeping the underlying duplication observable instead of silently dropping the extra rows. Behaviour is unchanged: empty -> 404, otherwise the first row is returned. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Read WORKSPACE_ID from the reactive context (as findByIds does) so the fan-out WARN carries both the trace id and the workspace, making the duplication easier to attribute. No behavioural change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extract the get-by-id row reduction into a package-private static (firstOrLogFanOut) and unit-test it: empty -> empty, single -> that row, and >1 -> first row without throwing IndexOutOfBoundsException. Pure unit test, no DB harness. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…er trace The get-by-id 500 (and the reducer's non-deterministic experiment pick) came from SELECT_BY_IDS fanning a trace into one row per experiment: the outer LEFT JOIN experiments_agg had no per-trace dedup. Collapse experiments_agg to a single canonical row per trace_id (most recent experiment by UUIDv7-ordered id) so the trace-by-id join returns exactly one row. Also fixes batch getByIds returning duplicate rows for multi-experiment traces. The findById reducer stays as a defensive detector (logs + returns first if a fan-out ever reappears). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
❓ question | Logical Bugs — adjacent query (out of scope for this PR) Posting as a general comment since the referenced lines aren't in this PR's diff. The sibling list query
So a trace belonging to ≥2 experiments would seem to fan the list result into duplicate trace rows there too — the same class of bug, minus the 500 (the list reducer isn't strict-single). This PR is correctly scoped to the get-by-id 500, so not a blocker here. Is the list-query fan-out already known/tracked, or worth a follow-up ticket? (Matches the recurring CTE fan-out pattern.) 🤖 Review posted via /review-github-pr |
|
Good catch, and correct. Verified: Agreed it is out of scope here (this PR is the get-by-id 500). It was not yet tracked — filed a follow-up: OPIK-7416, with the same collapse ( |
andrescrz
left a comment
There was a problem hiding this comment.
Just minor recommendations, otherwise LGTM.
| public Mono<Trace> findById(@NonNull UUID id, @NonNull Connection connection) { | ||
| return findByIds(List.of(id), connection) | ||
| .singleOrEmpty(); | ||
| .collectList() |
There was a problem hiding this comment.
Minor: for checking size > 1 and logging and checking the first one, there might be an easy way to avoid collecting all results (waits and accumulates in memory). Ok to keep this if the alternative is too complicated and not a clean implementation. Results should be small anyway.
There was a problem hiding this comment.
Keeping collectList() here. Now that SELECT_BY_IDS collapses experiments_agg to one row per trace_id, this buffers at most one row (empty on miss), so there is nothing to accumulate in practice — the reducer stays only as a defensive fan-out detector. Agree the alternative (bounded reactive reduce) is not worth the added complexity here.
- Annotate firstOrLogFanOut with @VisibleForTesting (it exists for the unit test). - Move the interpolated values to the end of the fan-out WARN (grep/service convention). Kept collectList(): SELECT_BY_IDS now collapses experiments_agg to one row per id, so the reducer buffers at most one row; it remains only as a defensive fan-out detector. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Details
GET /v1/private/traces/{id}intermittently returned 500 withIndexOutOfBoundsException: "Source emitted more than one item", and — once the reducer was made lenient — could return a non-deterministicexperimentfield. Root cause: inSELECT_BY_IDS, the outerLEFT JOIN experiments_agg eaag ON eaag.trace_id = t.idhad no per-trace dedup, so a trace that belongs to N experiments fanned out into N rows (differingexperiment_id/name/dataset_id).experiments_aggnow collapses to a single canonical row pertrace_id— the most recent experiment (ORDER BY trace_id, experiment_id DESC+LIMIT 1 BY trace_id, ids are UUIDv7 / time-ordered). The trace-by-id join therefore returns exactly one row. This also stops batchgetByIdsreturning duplicate rows for multi-experiment traces.Trace.experimentis a single field, so collapsing loses nothing at the API level.findByIdreduces viacollectList()+ first and logs a WARN (trace id + workspace) if a fan-out ever reappears, so a future regression degrades to a log + one row instead of a 500.Change checklist
Issues
AI-WATERMARK
AI-WATERMARK: yes
SELECT_BY_IDS, the defensive reducer, and the unit test.Testing
mvn -o test-compile+spotless:applyclean.TraceDAOImplTeston the reducer (no DB): no rows → empty; single → that; >1 → first, noIndexOutOfBoundsException.Tests run: 3, Failures: 0.GET /{id}returns 200 with a deterministic experiment) belongs in the traces integration suite (CI). Flagged on the ticket.Documentation
No documentation changes — internal reliability/correctness fix; no API shape change (
Trace.experimentremains a single value).