Skip to content

[OPIK-7707] [BE] perf: make dataset version count updates atomic - #7705

Merged
JetoPistola merged 8 commits into
mainfrom
danield/OPIK-7707-make-the-version-count-update-atomic
Aug 30, 2026
Merged

[OPIK-7707] [BE] perf: make dataset version count updates atomic#7705
JetoPistola merged 8 commits into
mainfrom
danield/OPIK-7707-make-the-version-count-update-atomic

Conversation

@JetoPistola

@JetoPistola JetoPistola commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Details

image

Every batch appended to an existing dataset version paid three MySQL round-trips inside the per-dataset lock that serialises the upload: findById, then updateCounts with totals computed in Java, then a getVersionById re-read of the row just written. Because the lock serialises batches, those round-trips are additive across an upload — a 100-batch upload pays them 100 times in sequence. This replaces the read-modify-write with a single atomic SQL increment and drops the re-read, taking the insert path from three statements to one.

  • DatasetVersionDAO.updateCounts (absolute values) becomes incrementCounts, which applies signed deltas in the database (SET items_total = items_total + :delta). One method serves both paths: +n on insert, -n total / +n deleted on delete. It returns the affected-row count so the service preserves the NotFoundException that findById().orElseThrow() used to provide.
  • Service-side, both paths funnel through one private updateVersionCounts(...) that owns the transaction, the DAO call, and the zero-row NotFoundException guard. updateVersionCountsForInsert / ...ForDelete remain as thin named wrappers documenting which counters each path moves.
  • The post-insert getVersionById is gone. No consumer reads the returned DatasetVersion — the REST endpoint returns 204 with the .block() result unassigned, and saveBatch maps it to items.size() — so insertItemsIntoVersion now returns Mono<Void> rather than fabricating a partially-populated object that would look real to a future caller.
  • The delete path is converted to the same atomic increment. It saves no round-trip — both callers already fetch the version for their own logging — but the arithmetic no longer depends on withDatasetVersionLock for mutual exclusion, and insert/delete now write counts the same way.
  • Counter semantics are unchanged; this changes only how counts are written.

Two notes for reviewers, since they differ from the ticket as filed:

  • The ticket cites an existing test InsertClassificationCounts as a regression net for this path. No test by that name exists in the tree; the real coverage is in DatasetVersionResourceTest (VersionSnapshotTests, ApplyDatasetItemChanges, MutateLatestVersion).
  • The ticket assumes the delete path shares the insert path's wasteful shape. It doesn't — expect no latency change there, only the consistency and lock-independence win described above.

updateCounts had zero remaining callers once both paths were converted, so it was removed rather than left as dead code.

Change checklist

  • User facing
  • Documentation update

Issues

  • Resolves OPIK-7707

AI-WATERMARK

AI-WATERMARK: yes

  • Tools: Claude Code
  • Model(s): Claude Opus 5 (1M context)
  • Scope: full implementation (code + tests)
  • Human verification: CI green (all 16 integration groups); pending human code review

Testing

Added a AtomicVersionCountUpdates nested class in DatasetVersionResourceTest with six tests:

  • single-batch append asserts the full counter triple, including that a re-sent existing item moves itemsModified but not itemsTotal
  • multi-batch append via the batch_group_id entrypoint accumulates into one version
  • 8 threads × 25 concurrent increments hitting the DAO directly with withDatasetVersionLock bypassed, asserting exact counters — this is the acceptance criterion that the arithmetic no longer depends on the lock; the previous read-modify-write would lose updates here
  • delete path: negative delta decrements items_total and raises items_deleted, leaving added/modified untouched
  • increment against an unknown version affects zero rows (the NotFoundException path)
  • increment scoped to a foreign workspace affects zero rows and leaves counters untouched (added in review follow-up)

Commands run:

  • mvn -o compile -DskipTests — passes
  • mvn -o test-compile -DskipTests — passes
  • mvn -o spotless:checkBUILD SUCCESS

CI result: all 16 backend integration groups pass. DatasetVersionResourceTest$AtomicVersionCountUpdates reports Tests run: 6, Failures: 0, Errors: 0 (Integration Group 6), so all six new tests executed and passed — including the lock-bypassed concurrency test. Pre-existing regression nets on these paths are green as well: MutateLatestVersion (6), ConcurrentUploads (4), DeleteItemsWithVersioning (5), BatchVersioningTests, BatchVersioningDeleteTests (10).

Note on local runs: mvn -o test -Dtest=DatasetVersionResourceTest could not be brought to green on my machine — three attempts each died in the test-class constructor, before any test method, with ClickHouse exception, code: 159 ... Read timed out on ON CLUSTER DDL during Liquibase migration, on a different pre-existing changeset each time (000097, 000109, 000070). That is local distributed-DDL latency, not this diff: the change is MySQL-only and all three changesets are already on main. CI, which has a healthy container environment, runs the suite clean.

Also verified by inspection: the counter columns are signed INT (000036_add_dataset_versions_tables.sql), so the delete path's negative delta cannot hit an unsigned-underflow error under strict mode — same semantics as the previous Java-side subtraction. No migration is required.

Review follow-up (0eb086558e, 0153bc85dc)

updateVersionCountsForDelete now checks the affected-row count from incrementCounts and throws NotFoundException on zero, matching the insert path; the helper's duplicate log.info was dropped (the callers already log deletedCount, versionId, and the resulting total). Note this failure mode is not reachable today — both delete callers call getVersionById first, which already throws — but insert and delete were handling a zero-row result inconsistently, which would become a real bug once the lock is narrowed or the pre-fetch removed.

That guard left the two helpers identical apart from their delta arguments, so a second follow-up folded the shared transaction / DAO-call / guard into one updateVersionCounts(...) taking signed deltas. Pure refactor, no behavioural change: verified by AtomicVersionCountUpdates (6), MutateLatestVersion (6, insert wrapper) and DeleteItemsWithVersioning (5, delete wrapper) all green on 0153bc85dc.

Documentation

N/A — internal performance and correctness-hardening change with no user-facing or API surface.

@github-actions github-actions Bot added java Pull requests that update Java code Backend tests Including test files, or tests related like configuration. 🟡 size/M labels Aug 3, 2026
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

⏱️ pre-commit per-hook timing

Hook Description Result Duration
☕ spotless — java backend Format Java code 6.21s
🛡️ semgrep — java backend sql Block SQL injection-prone string formatting 1.88s
Total (2 ran) 8.09s
⏭️ 42 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 ⏭️
🧪 rebaseline script tests Self-test the changelog re-baseline script ⏭️
🌐 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 ⏭️
🌈 zizmor — github workflows security Security-scan GitHub Actions workflows ⏭️

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

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Backend Tests - Integration Group 13

 39 files  +  3   39 suites  +3   4m 37s ⏱️ +41s
362 tests +109  357 ✅ +106  5 💤 +3  0 ❌ ±0 
325 runs  + 85  320 ✅ + 82  5 💤 +3  0 ❌ ±0 

Results for commit 47a56f8. ± Comparison against base commit 89ce756.

This pull request removes 109 and adds 218 tests. Note that renamed tests count towards both.
com.comet.opik.api.resources.v1.events.webhooks.AlertBucketServiceTest ‑ addEventToBucket__whenAddingToSameAlertAfterConfigChange__shouldUseOriginalWindow
com.comet.opik.api.resources.v1.events.webhooks.AlertBucketServiceTest ‑ addEventToBucket__whenConfigChanges__shouldCreateSeparateBucketsWithDifferentWindows
com.comet.opik.api.resources.v1.events.webhooks.AlertBucketServiceTest ‑ addEventToBucket__whenFirstEvent__shouldSetTtl
com.comet.opik.api.resources.v1.events.webhooks.AlertBucketServiceTest ‑ addEventToBucket__whenFirstEvent__shouldStoreWindowSizeFirstSeenAndWorkspaceId
com.comet.opik.api.resources.v1.events.webhooks.AlertBucketServiceTest ‑ addEventToBucket__whenSubsequentEvents__shouldNotRefreshTtl
com.comet.opik.api.resources.v1.events.webhooks.AlertBucketServiceTest ‑ addEventToBucket__whenSubsequentEvents__shouldPreserveOriginalWindowSize
com.comet.opik.api.resources.v1.events.webhooks.AlertBucketServiceTest ‑ deleteBucket__shouldRemoveBucketFromRedis
com.comet.opik.api.resources.v1.events.webhooks.AlertBucketServiceTest ‑ getBucketData__shouldReturnAllEventIdsAndPayloads
com.comet.opik.api.resources.v1.events.webhooks.AlertBucketServiceTest ‑ getBucketsReadyToProcess__afterConfigChange__shouldUseStoredWindowSizes
com.comet.opik.api.resources.v1.priv.DatasetExperimentE2ETest$FilterDatasetsByExperimentWith ‑ when__filteringByDatasetsWithExperimentsAfterAnExperimentIsDeleted__thenShouldReturnTheDatasetWithExperiments
…
com.comet.opik.api.resources.v1.events.DatasetExportJobSubscriberResourceTest$ConfigurationTests ‑ shouldVerifyStreamConfiguration
com.comet.opik.api.resources.v1.events.DatasetExportJobSubscriberResourceTest$ConfigurationTests ‑ shouldVerifySubscriberIsEnabled
com.comet.opik.api.resources.v1.events.DatasetExportJobSubscriberResourceTest$EdgeCaseTests ‑ shouldCompleteExport_whenDatasetDoesNotExist
com.comet.opik.api.resources.v1.events.DatasetExportJobSubscriberResourceTest$SuccessTests ‑ shouldProcessExportJobSuccessfully_forEmptyDataset
com.comet.opik.api.resources.v1.events.DatasetExportJobSubscriberResourceTest$SuccessTests ‑ shouldProcessExportJobSuccessfully_whenDatasetHasItems
com.comet.opik.api.resources.v1.events.DatasetExportJobSubscriberResourceTest$SuccessTests ‑ shouldProcessExportJobWithLargeDataset
com.comet.opik.api.resources.v1.events.DatasetExportJobSubscriberResourceTest$SuccessTests ‑ shouldProcessMultipleExportJobsInParallel
com.comet.opik.api.resources.v1.priv.DashboardsResourceTest$BatchDeleteDashboards ‑ batchDeleteFromDifferentWorkspaceReturns204
com.comet.opik.api.resources.v1.priv.DashboardsResourceTest$BatchDeleteDashboards ‑ batchDeleteMultipleExistingDashboards
com.comet.opik.api.resources.v1.priv.DashboardsResourceTest$BatchDeleteDashboards ‑ batchDeleteSingleDashboard
…

♻️ This comment has been updated with latest results.

@JetoPistola

Copy link
Copy Markdown
Contributor Author

Consolidated duplicate work from #7928 into this PR (now closed).

I'd independently implemented the same change there without noticing this PR existed. This one is the better base — it has the DAO-level lock-bypass test that actually proves the arithmetic is atomic rather than merely serialised by withDatasetVersionLock, plus DAO coverage for negative deltas, unknown version, and cross-workspace isolation.

Ported here in 7ad36cf, one substantive gap the other PR had caught:

COALESCE(col, 0) on all four counters. The columns are INT DEFAULT 0 in migration 000036 — nullable, since DEFAULT only applies when the column is omitted on INSERT — and DatasetVersion maps all four as boxed Integer. A bare col + :delta leaves NULL as NULL, which then unboxes to an NPE on read. The absolute updateCounts this replaces happened to repair a NULL by overwriting it, so without the COALESCE the switch to increments is a regression in NULL tolerance.

Added a test that nulls the counters directly and asserts the increment treats them as zero — verified it fails without the COALESCE and passes with it.

Nothing else from #7928 was worth porting. In particular I did not bring over its removal of the delete-path NotFoundException: a bot review there flagged it as a 404 regression, but 0eb0865's reasoning here is correct — both delete callers pre-fetch via getVersionById, which already throws, so that path is unreachable today and the throw keeps insert/delete symmetric for later work that drops the pre-fetch.

Suite is green locally: 125/125 in DatasetVersionResourceTest.

Note this branch is still based on Aug 3 and hasn't had main merged in — worth doing before merge.

🤖 Comment posted via /address-github-pr-comments

@JetoPistola
JetoPistola marked this pull request as ready for review August 24, 2026 06:33
@JetoPistola
JetoPistola requested a review from a team as a code owner August 24, 2026 06:33
@CometActions

CometActions commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Already covered by a test in this PR.

The delta rewrite of the version counters is covered on both write paths. Insert: tests_end_to_end/e2e/tests/datasets/dataset-version-counters.spec.ts pins items_total/added/modified after multi-batch and 8-thread parallel inserts and reads the same numbers back off the Version history tab, so a wrong sign or a swapped argument in incrementCounts fails it. Delete: the draft stacked on this branch, #7705 -> #7965, adds dataset-version-delete-counters.spec.ts, which asserts the full total/added/modified/deleted quadruple across interleaved inserts, upserts and deletes (the existing delete specs only count rows). Nothing new to propose. The ITEMS_TOTAL_NOT_MIGRATED (-1) seeding in ensureVersion1Exists is the one part we can't reach: it only appears on a dataset lazily migrated from the pre-versioning tables, which a fresh OSS install never produces -- your DatasetVersionResourceTest covers those semantics including the deliberate 404, so we're recording it as deferred rather than proposing a spec we can't run.

Not testable yet. ensureVersion1Exists now seeds items_total with the -1 not-migrated sentinel instead of 0, and incrementCounts / updateItemsTotal / batchUpdateItemsTotal are all gated on it. That is user-facing on a legacy dataset: while the row holds the sentinel the Version history tab has no real Item count to show, and an item batch written into that window is refused with 404 by design. It is only reachable on an install that already has pre-versioning dataset_items rows with no dataset_versions row, which the e2e estate cannot produce -- every dataset it creates is versioned from birth.

also touches Backend (Java API / internal)

Run

Advisory, from the QA test radar. Nothing here blocks this PR, and anything it proposes is a draft for review.

Re-checked after a push on 27 Aug 16:33 UTC.

@CometActions CometActions added the test-environment Deploy Opik adhoc environment label Aug 24, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🔄 Test environment deployment process has started

Phase 1: Deploying base version 2.2.14-6218 (from main branch) if environment doesn't exist
Phase 2: Building new images from PR branch danield/OPIK-7707-make-the-version-count-update-atomic
Phase 3: Will deploy newly built version after build completes

You can monitor the progress here.

@CometActions

Copy link
Copy Markdown
Collaborator

Test environment is now available!

To configure additional Environment variables for your environment, run [Deploy Opik AdHoc Environment workflow] (https://github.com/comet-ml/comet-deployment/actions/workflows/deploy_opik_adhoc_env.yaml)

Access Information

The deployment has completed successfully and the version has been verified.

@CometActions

Copy link
Copy Markdown
Collaborator

🌙 Nightly cleanup: The test environment for this PR (pr-7705) has been cleaned up to free cluster resources. PVCs are preserved — re-deploy to restore the environment.

@CometActions CometActions removed the test-environment Deploy Opik adhoc environment label Aug 25, 2026
@JetoPistola JetoPistola added the test-environment Deploy Opik adhoc environment label Aug 25, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🔄 Test environment deployment process has started

Phase 1: Deploying base version 2.2.14-6218 (from main branch) if environment doesn't exist
Phase 2: Building new images from PR branch danield/OPIK-7707-make-the-version-count-update-atomic
Phase 3: Will deploy newly built version after build completes

You can monitor the progress here.

@CometActions

Copy link
Copy Markdown
Collaborator

Test environment is now available!

To configure additional Environment variables for your environment, run [Deploy Opik AdHoc Environment workflow] (https://github.com/comet-ml/comet-deployment/actions/workflows/deploy_opik_adhoc_env.yaml)

Access Information

The deployment has completed successfully and the version has been verified.

@CometActions

Copy link
Copy Markdown
Collaborator

🌙 Nightly cleanup: The test environment for this PR (pr-7705) has been cleaned up to free cluster resources. PVCs are preserved — re-deploy to restore the environment.

@CometActions CometActions removed the test-environment Deploy Opik adhoc environment label Aug 26, 2026
The sentinel gate added for the backfill-clobber finding broke lazy
migration. ensureVersion1Exists seeds items_total = 0, not -1, so the
newly gated updateItemsTotal matched zero rows and countAndUpdateItemsTotal
discarded that result: the real count was never written, and the version
stayed at 0 permanently. The batch backfill could not repair it either,
since it also selects only on the sentinel.

Seed ITEMS_TOTAL_NOT_MIGRATED instead. A literal 0 on a row created before
its items are counted is indistinguishable from a genuinely empty version,
which is what let the failure hide.

Also stop discarding the affected-row count in countAndUpdateItemsTotal:
zero rows now means an API write already moved the counter off the
sentinel, so the stale count is correctly skipped rather than silently
dropped. Logged rather than thrown -- skipping is the right outcome there.

Addresses review feedback on #7705.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ensureVersion1Exists guarded on NOT EXISTS (SELECT 1 FROM dataset_versions
WHERE dataset_id = :dataset_id) with no workspace predicate, so a matching
dataset_id in another workspace could suppress v1 creation in this one and
leak that state through the affected-row count. Every other statement here
scopes by workspace, and the table's uniqueness constraint is
(workspace_id, dataset_id, version_hash) -- this subquery was the outlier.

Predates this branch, but seeding the sentinel made the affected-row result
load-bearing, so a wrong NOT EXISTS now matters more than it did.

Also move the skip log out of the inTransaction callback in
countAndUpdateItemsTotal: the callback returns the affected-row count and
the branch runs outside, per the database-work-only rule in
.agents/skills/opik-backend/mysql.md.

Addresses review feedback on #7705.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Aggregated logs can't tell which value is which in "for dataset '{}'
version '{}'". Matches the labelled form used elsewhere in this branch.

Addresses review feedback on #7705.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@JetoPistola JetoPistola added test-environment Deploy Opik adhoc environment keep-test-env labels Aug 27, 2026
@JetoPistola
JetoPistola marked this pull request as ready for review August 27, 2026 16:25
@JetoPistola
JetoPistola requested a review from thiagohora August 27, 2026 16:25
@github-actions

Copy link
Copy Markdown
Contributor

🔄 Test environment deployment process has started

Phase 1: Deploying base version 2.2.42-6455 (from main branch) if environment doesn't exist
Phase 2: Building new images from PR branch danield/OPIK-7707-make-the-version-count-update-atomic
Phase 3: Will deploy newly built version after build completes

You can monitor the progress here.

@CometActions

Copy link
Copy Markdown
Collaborator

Test environment is now available!

To configure additional Environment variables for your environment, run [Deploy Opik AdHoc Environment workflow] (https://github.com/comet-ml/comet-deployment/actions/workflows/deploy_opik_adhoc_env.yaml)

Access Information

The deployment has completed successfully and the version has been verified.

@CometActions

Copy link
Copy Markdown
Collaborator

🌙 Nightly cleanup: The keep-test-env label kept pr-7705 running through this cleanup cycle. The label has now been removed, so this environment will be torn down on the next nightly run unless you re-add keep-test-env.

@CometActions

Copy link
Copy Markdown
Collaborator

🌙 Nightly cleanup: The test environment for this PR (pr-7705) has been cleaned up to free cluster resources. PVCs are preserved — re-deploy to restore the environment.

@CometActions CometActions removed the test-environment Deploy Opik adhoc environment label Aug 29, 2026
@JetoPistola JetoPistola added test-environment Deploy Opik adhoc environment keep-test-env labels Aug 30, 2026
@CometActions

Copy link
Copy Markdown
Collaborator

🌙 Nightly cleanup: The keep-test-env label kept pr-7705 running through this cleanup cycle. The label has now been removed, so this environment will be torn down on the next nightly run unless you re-add keep-test-env.

@JetoPistola JetoPistola added test-environment Deploy Opik adhoc environment and removed test-environment Deploy Opik adhoc environment labels Aug 30, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🔄 Test environment deployment process has started

Phase 1: Deploying base version 2.2.42-6455 (from main branch) if environment doesn't exist
Phase 2: Building new images from PR branch danield/OPIK-7707-make-the-version-count-update-atomic
Phase 3: Will deploy newly built version after build completes

You can monitor the progress here.

@CometActions

Copy link
Copy Markdown
Collaborator

Test environment is now available!

To configure additional Environment variables for your environment, run [Deploy Opik AdHoc Environment workflow] (https://github.com/comet-ml/comet-deployment/actions/workflows/deploy_opik_adhoc_env.yaml)

Access Information

The deployment has completed successfully and the version has been verified.

@JetoPistola
JetoPistola merged commit 8d7afc1 into main Aug 30, 2026
126 of 131 checks passed
@JetoPistola
JetoPistola deleted the danield/OPIK-7707-make-the-version-count-update-atomic branch August 30, 2026 06:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Backend baz: pending java Pull requests that update Java code 🟠 size/L test-environment Deploy Opik adhoc environment tests Including test files, or tests related like configuration.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants