[OPIK-7707] [BE] refactor: make dataset version count updates atomic - #7928
[OPIK-7707] [BE] refactor: make dataset version count updates atomic#7928JetoPistola wants to merge 3 commits into
Conversation
Replace the read-modify-write on the version counters with a single incrementing statement, and drop the post-insert re-read of the row that was just written. The insert path previously did findById -> add in Java -> updateCounts, then called getVersionById to re-read the same row. Three MySQL round-trips per batch, all inside the per-dataset lock that serialises an upload, so a 100-batch upload paid them 100 times in sequence. incrementCounts applies the deltas in the database, so the arithmetic no longer depends on the lock for mutual exclusion. Both delete call sites are converted too -- they already passed a plain delta and only needed currentVersion for logging -- so insert and delete write counts the same way. updateCounts had no callers left and is removed. The returned DatasetVersion is not re-read: the resource blocks and returns 204, and saveBatch maps it to the item count, so no caller reads its fields. The Mono still emits so saveBatch's map is reached. Counter values are unchanged; this is a performance and correctness-hardening change with no intended behavioural surface. Adds two concurrency tests asserting items_total agrees with the rows actually stored after 8 concurrent inserts and 8 concurrent deletes against one version. Note both also pass without this change: the per-dataset lock does serialise these writes today, so the tests pin the invariant rather than demonstrating a fixed race. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
⏱️ pre-commit per-hook timing
⏭️ 42 skipped (no matching files changed)
|
|
No test needed here. The counter arithmetic is unchanged — read-modify-write of (total, added, modified, deleted) becomes the same deltas applied in one UPDATE — so the only behaviour that differs is under concurrent writers, and that isn't something an e2e run can reproduce deterministically. Our spec Advisory, from the QA test radar. Nothing here blocks this PR, and anything it proposes is a draft for review. |
…mic-version-count-update
Addresses review feedback on #7928. COALESCE all four counters in incrementCounts. The columns are `INT DEFAULT 0` (nullable) and DatasetVersion maps them as boxed Integer, so a NULL survives the increment and later unboxes to an NPE. The absolute update this replaced happened to repair a NULL by overwriting it; a bare `col + :delta` propagates it instead. Stop throwing NotFoundException from the delete path when the version row is gone. A concurrent dataset delete removes dataset_versions without holding withDatasetVersionLock, and the previous absolute update ignored its result, so that race stayed a 204. Turning it into a 404 would have been an unintended contract change; log and move on. Demote the delete count log to DEBUG and label the field -- it duplicated the caller's "Deleted ... items" INFO line. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| // A concurrent dataset delete can drop the version row: the previous absolute update | ||
| // ignored its result, so a vanished version stayed a 204. Keep that idempotency rather | ||
| // than turning the race into a 404. | ||
| if (dao.incrementCounts(versionId, -deletedCount, 0, 0, deletedCount, workspaceId, userName) == 0) { |
There was a problem hiding this comment.
Large deletions corrupt version counters
The Long from countItemsMatchingFilters is narrowed with deletedCount.intValue() at both deletion call sites, and because removeItemsFromVersionByFilters can delete every row for a dataset_id with null/empty filters, counts above Integer.MAX_VALUE wrap so -deletedCount can increase items_total while applying a negative items_deleted delta. Should we reject or bound counts before narrowing, or preserve a wide representation through the storage/API contract?
Want Baz to fix this for you? Activate Fixer
Other fix methods
Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
`apps/opik-backend/src/main/java/com/comet/opik/domain/DatasetItemService.java` around
lines 1777-1777, fix the deletion-count handling in `removeItemsFromVersionByFilters`
and its callers: `countItemsMatchingFilters` returns a `Long`, but converting it with
`intValue()` can overflow before `incrementCounts` applies the negative delta. Reject or
explicitly bound counts before narrowing, or preserve a wide numeric type consistently
through the service and persistence/API contract, ensuring oversized deletions cannot
increase `items_total` or apply negative `items_deleted` values.
Details
Replaces the read-modify-write on the dataset version counters with a single incrementing SQL statement, and drops the post-insert re-read of the row that was just written. Both were deferred from OPIK-7705 as follow-ups.
The insert path previously did
findById→ add in Java →updateCounts, then calledgetVersionByIdto re-read the same row: three MySQL round-trips per batch, all inside the per-dataset lock that serialises an upload, so a 100-batch upload paid them 100 times in sequence.incrementCountsapplies the deltas in the database (SET items_total = COALESCE(items_total, 0) + :delta), so the counter arithmetic no longer depends onwithDatasetVersionLockfor mutual exclusion. TheCOALESCEmatters: the columns areINT DEFAULT 0(nullable) and map to boxedInteger, so a NULL would survive a bare increment and later unbox to an NPE — the absolute update this replaces happened to repair a NULL by overwriting it.currentVersionfor logging, so insert and delete now write counts the same way — this closes the FR asking that the delete path not be left disagreeing with insert.DatasetService.deletecan removedataset_versionswithout holdingwithDatasetVersionLock; the previous absolute update discarded its result, so that race was a silent 204. It logs and continues rather than turning into a 404. The insert path does still throw — it threw fromfindByIdbefore, so that 404 is existing behaviour.updateCountshad no callers left after both paths moved over, so it is removed.DatasetVersionis no longer re-read. The resource.block()s and returns204, andsaveBatchmaps it toitems.size(), so no caller reads its fields; the Mono still emits sosaveBatch'smapis reached.Counter values are unchanged. This is a performance and correctness-hardening change with no intended behavioural surface.
Change checklist
Issues
AI-WATERMARK
AI-WATERMARK: yes
Testing
Scenarios validated:
DatasetVersionResourceTestsuite (123 tests) is green, includingInsertClassificationCounts(new-vs-update classification),BatchVersioningDeleteTests,DeleteItemsWithVersioning, andMutateLatestVersion. This is the AC that counter values match the previous implementation.ConcurrentUploads, reusing that class's existing barrier harness so the HTTP calls genuinely overlap: 8 concurrent inserts into one version, and 8 concurrent deletes from one version, each assertingitems_totalequals the rows actually stored.getVersionByIdfollows it.origin/mainmerged in (no conflicts) and the full 123 re-run green on the merged tree, after the review fixes.Not verified, stated plainly:
DatasetsResourceTest$FindDatasetshas 30 pre-existing failures on this branch. Confirmed unrelated: the identical 30 fail on a clean tree with all of this PR's changes stashed. Separately, a ClickHouse testcontainer failed to boot on one run — flaky infra, passed on retry.Documentation
No documentation impact — internal refactor with no API or behavioural change.