Skip to content

[OPIK-7891] [BE] fix: count distinct item ids when sizing a dataset version - #7966

Merged
JetoPistola merged 5 commits into
mainfrom
danield/OPIK-7891-items-total-inflation
Aug 31, 2026
Merged

[OPIK-7891] [BE] fix: count distinct item ids when sizing a dataset version#7966
JetoPistola merged 5 commits into
mainfrom
danield/OPIK-7891-items-total-inflation

Conversation

@JetoPistola

@JetoPistola JetoPistola commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Details

Stacked on #7705 (OPIK-7707). Base branch is danield/OPIK-7707-make-the-version-count-update-atomic, so this PR's diff shows only the OPIK-7891 fix. Merge #7705 first; this retargets to main automatically once it lands.

image

DatasetItemVersionDAO.insertItems returned items.size() — the raw list length. That is deliberate, not an oversight: ClickHouse async inserts report 0 rows before commit, so the code returns the count of what it handed in rather than trusting getRowsUpdated. But reads collapse a repeated dataset_item_id to a single row via LIMIT 1 BY, so a stable id appearing twice in one batch made the stored version total one higher than the rows the version actually holds.

Every version total flows through that return value — createFirstVersion feeds it straight into itemsTotal, and applyDelta sums added + edited + copied — so the fix belongs at the source: count distinct stable ids, matching what the storage engine keeps.

  • Counts DISTINCT datasetItemId in insertItems — the same field the INSERT below binds, so counting and writing share one definition of item identity. (An earlier revision added an item.id() fallback for a null datasetItemId; it was removed after review. The INSERT binds datasetItemId().toString() with no null check, so a null cannot survive to be counted either way, and every caller normalizes the field first.)
  • Fixes all four call sites at once rather than patching one caller and leaving the others inflated.

This corrects the mechanism recorded on the ticket

The ticket described this as asynchronous ClickHouse visibility racing concurrent batches on the append path, reachable only via num_threads>1. Investigation shows it is none of those things:

Reported Actual
A race between concurrent batches Deterministic; one single-threaded request reproduces it
Requires num_threads>1 Parallelism is irrelevant to the defect
In the append path (countExistingItemIds) In the version-creating path (insertItemscreateFirstVersion)

The append path is genuinely correct — countExistingItemIds classifies a cross-batch duplicate as an update. A test in this PR covers that case and passes both with and without the fix, which is what rules the append path out.

num_threads was a red herring: the SDK's content-hash dedup drops same-content duplicates before batching, so a duplicate id only survives into a request when the content differs. The reporter's parallel runs were simply the ones where such an id landed in the version-creating batch.

Change checklist

  • User facing
  • Documentation update

User-facing: the "Item count" in Datasets → dataset → Version history (and dataset_items_count in the SDK) now agrees with the rows the version holds. No API shape change.

Issues

  • Resolves #
  • OPIK-7891

AI-WATERMARK

AI-WATERMARK: yes

  • Tools: Claude Code
  • Model(s): Claude Opus 5
  • Scope: Traced the call graph, wrote the fix and both tests, ran the suites and the with/without-fix comparisons below.
  • Human verification: Pending author review.

Testing

mvn test -Dtest='DatasetVersionResourceTest*'                       # 127/127 pass
mvn test -Dtest='DatasetsResourceTest$CreateDatasetItems,...'       # 45/45 pass
mvn spotless:check                                                  # clean

Two tests added, deliberately covering both shapes, and each was run with and without the fix:

Scenario Without fix With fix
Duplicate stable id inside the version-creating batch FAILSitems_total 3, rows stored 2 passes
Duplicate stable id spanning two batches in one batch_group_id passes passes

The first is the regression test. The second is a negative control: it pins the append path's existing correct behaviour and is the evidence that the reported async-visibility mechanism is not the cause. Both assert items_total against the rows actually returned for the version, rather than against a hardcoded number, so a future change that breaks one but not the other still fails.

Not verified:

  • No measurement of production impact on already-drifted datasets. This prevents new inflation; it does not reconcile a version whose items_total is already wrong. Reconciliation is tracked separately under OPIK_7804.
  • The original report was against a deployed environment; the reproduction here is a local integration test. The mechanism is confirmed in code and by test, but I have not re-run the reporter's exact 1,200-item scenario.

Documentation

N/A — internal counter correctness fix with no API or configuration surface.

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

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

⏱️ pre-commit per-hook timing

Hook Description Result Duration
☕ spotless — java backend Format Java code 4.79s
🛡️ semgrep — java backend sql Block SQL injection-prone string formatting 2.12s
Total (2 ran) 6.91s
⏭️ 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 ⏭️

@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Python SDK E2E Tests Results (Python 3.10)

296 tests  ±0   289 ✅ +1   5m 11s ⏱️ +38s
  1 suites ±0     7 💤  - 1 
  1 files   ±0     0 ❌ ±0 

Results for commit 10a4d68. ± Comparison against base commit 7ad36cf.

♻️ This comment has been updated with latest results.

JetoPistola pushed a commit that referenced this pull request Aug 24, 2026
Drop the fall back to item.id() when datasetItemId is null. It looked
defensive but was worse than nothing: the INSERT ten lines below binds
item.datasetItemId().toString() with no null check, so a null cannot
survive to be counted either way, and every caller normalizes
datasetItemId before reaching here. All the fallback added was a second,
divergent definition of item identity in the one place that must agree
with what the INSERT writes.

Addresses review feedback on #7966.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@JetoPistola
JetoPistola marked this pull request as ready for review August 24, 2026 08:43
@JetoPistola
JetoPistola requested a review from a team as a code owner August 24, 2026 08:43
@CometActions

CometActions commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Already covered by a test in this PR.

The fix is a real user-facing one — items_total is what the Version history tab renders as "Item count", so before this a batch repeating a dataset_item_id showed a version claiming more items than the dataset holds. The existing merged spec dataset-version-counters.spec.ts does not catch it: its 1200/600-item seeds are all-unique ids, so items.size() and the distinct count are the same number and it passes either way. What does catch it is the QA draft stacked on your branch, #7969tests_end_to_end/e2e/tests/datasets/dataset-version-duplicate-ids.spec.ts sends a 5-row batch with 4 distinct ids and asserts itemsTotal == 4 on the create path and on the delta path, plus the rendered "Item count". That fails on items.size(). So no new exploration or spec here — but that coverage lives in an unmerged draft, so if #7969 is closed rather than merged this change goes back to being uncovered end to end. Your two DatasetVersionResourceTest cases pin the same behaviour at the API level.

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 30 Aug 06:05 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-7891-items-total-inflation
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-7966) 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-7891-items-total-inflation
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-7966) 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
andrescrz
andrescrz previously approved these changes Aug 26, 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.

LGTM, just a minor test assertion improvement to make them more reliable. Not a blocker.

JetoPistola pushed a commit that referenced this pull request Aug 26, 2026
Both OPIK-7891 tests checked stored.size() and left the row identities
unasserted. On a de-duplication test that is the weakest possible check:
the count is exactly what a bug is most likely to keep right. Keeping the
wrong revision of the duplicate, or dropping the distinct row and keeping
both duplicates, would each leave the size at 2 and pass.

Now both name the rows that must survive and compare whole objects via
the shared IGNORED_FIELDS_DATA_ITEM element comparator, so a wrong-winner
bug fails. This also pins behaviour the tests previously only assumed:
the last submitted revision of a repeated id is the one that survives.

The itemsTotal assertion still ties the counter to stored.size() rather
than a literal, which is only meaningful now that stored itself is pinned.

Addresses review feedback on #7966.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JetoPistola pushed a commit that referenced this pull request Aug 27, 2026
Every row in a batch is written with the same now64(9), and the read
dedupes with ORDER BY dataset_item_id DESC, last_updated_at DESC
LIMIT 1 BY dataset_item_id -- no tie-breaker. Which revision of a
repeated id survives is therefore unspecified, so asserting that the
later payload wins was pinning an accident.

Assert what is guaranteed instead: exactly one row per distinct id, and
the distinct item's content intact. Also corrects a comment that
described insertItems' pre-fix return value in the present tense.

Addresses review feedback on #7966.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@JetoPistola
JetoPistola marked this pull request as ready for review August 27, 2026 16:43
@JetoPistola

Copy link
Copy Markdown
Contributor Author

Will rebase, after approval / or when #7705 gets merged first, FYI @thiagohora @andrescrz

image

Base automatically changed from danield/OPIK-7707-make-the-version-count-update-atomic to main August 30, 2026 06:00
petros-double-test1 and others added 5 commits August 30, 2026 09:00
…ersion

insertItems returned items.size() -- the raw list length, deliberately
not a DB row count, because ClickHouse async inserts report 0 rows
before commit. Reads collapse a repeated dataset_item_id to one row via
LIMIT 1 BY, so a stable id appearing twice in one batch made the version
total one higher than the rows the version actually holds.

Every version total flows through this value: createFirstVersion feeds it
straight to itemsTotal, and applyDelta sums added + edited + copied. So
the fix belongs at the source -- count distinct stable ids, matching what
the storage engine keeps.

This corrects the mechanism recorded on the ticket. The reported cause
was asynchronous ClickHouse visibility racing concurrent batches on the
append path; it is neither a race nor on the append path. One
single-threaded request reproduces it, and the appending path already
classifies a cross-batch duplicate correctly via countExistingItemIds --
verified by a test that passes with and without this change. num_threads
was a red herring: the SDK's content-hash dedup drops same-content
duplicates before batching, so a duplicate id only survives into a
request when the content differs.

Tests cover both shapes: a duplicate inside the version-creating batch
(fails without this fix) and a duplicate spanning two batches in one
batch_group_id (passes either way, pinning the append path's behaviour).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Drop the fall back to item.id() when datasetItemId is null. It looked
defensive but was worse than nothing: the INSERT ten lines below binds
item.datasetItemId().toString() with no null check, so a null cannot
survive to be counted either way, and every caller normalizes
datasetItemId before reaching here. All the fallback added was a second,
divergent definition of item identity in the one place that must agree
with what the INSERT writes.

Addresses review feedback on #7966.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both OPIK-7891 tests checked stored.size() and left the row identities
unasserted. On a de-duplication test that is the weakest possible check:
the count is exactly what a bug is most likely to keep right. Keeping the
wrong revision of the duplicate, or dropping the distinct row and keeping
both duplicates, would each leave the size at 2 and pass.

Now both name the rows that must survive and compare whole objects via
the shared IGNORED_FIELDS_DATA_ITEM element comparator, so a wrong-winner
bug fails. This also pins behaviour the tests previously only assumed:
the last submitted revision of a repeated id is the one that survives.

The itemsTotal assertion still ties the counter to stored.size() rather
than a literal, which is only meaningful now that stored itself is pinned.

Addresses review feedback on #7966.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
hasSize alone asserts a count and nothing about identity, so a bug that
preserves the count passes. Called out on #7966, where both new
de-duplication tests checked stored.size() and left the row identities
unasserted -- the count is exactly what a de-dup bug is most likely to
get right.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every row in a batch is written with the same now64(9), and the read
dedupes with ORDER BY dataset_item_id DESC, last_updated_at DESC
LIMIT 1 BY dataset_item_id -- no tie-breaker. Which revision of a
repeated id survives is therefore unspecified, so asserting that the
later payload wins was pinning an accident.

Assert what is guaranteed instead: exactly one row per distinct id, and
the distinct item's content intact. Also corrects a comment that
described insertItems' pre-fix return value in the present tense.

Addresses review feedback on #7966.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@JetoPistola
JetoPistola force-pushed the danield/OPIK-7891-items-total-inflation branch from 74d011a to 77a681e Compare August 30, 2026 06:00
@JetoPistola
JetoPistola removed request for a team August 30, 2026 06:36

@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.

One advice on the testing.md rules, but not a blocker. It can go on a follow-up PR.

rather than to a literal — but it is only as strong as the assertion on `stored` itself. Pin the
contents first, then tie the counter to them.

Reuse the shared ignore-field constants (`IGNORED_FIELDS_DATA_ITEM` and friends) rather than

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.

Not only should reuse the constant, many times there's an existing helper method or even helper class which should be used (generally delegates to the constant). That way we keep the assertions logic centralised.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed — and the point generalises past the constant. Tracked as OPIK-8181 so this PR can merge without growing further.

The dataset item tests are the clearest instance: IGNORED_FIELDS_DATA_ITEM is declared twice (DatasetsResourceTest:219 and, independently, ExperimentAggregatesIntegrationTest:130), DatasetVersionResourceTest imports it across package boundaries from a sibling resource test class, and ~10 call sites repeat the comparator chain by hand. Two copies of the list can drift silently.

The follow-up extracts a DatasetItemAssertions under api/resources/utils/ mirroring TraceAssertions, migrates the call sites, and reworks the skill-doc guidance to say helper-method-or-class over inlined constant. Doing it in a separate PR rather than here because it touches three large test classes and needs the two constant declarations diffed and reconciled deliberately — that reconciliation is its own reviewable decision, not a drive-by in a counter-correctness fix.

Note the hasSize doc section this thread is anchored on is no longer on the branch; restoring it, phrased around helper reuse, is part of the follow-up.

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.

@JetoPistola as general feedback, let's not create a ticket for small follow-up comments like this.

We send the PR or not, but as part of the same tickets.

We can't maintain a backlog of PR follow-up tickets, it's too much burden. In addition, it's not the purpose of the backlog.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@andrescrz - thanks for the feedback, appreciate it

Agreed - we shouldn't overload the backlog, especially with PR follow-up tickets (Like this comment) - avoiding ticket bloat, makes sense.

Perhaps my main concern was that touching three large test classes and reconciling the constant drift felt a bit too heavy for a drive-by change, in this correctness PR

That said, there were alternative approaches for me to take:

  1. Absorb It Immediately (The "Do It Now" Rule) - postponing merge, to avoid the follow up PR
  2. The Fast-Follow PR Without Jira - NA ticket - create draft, and iterate now/later
  3. Code-Level TODOs - perhaps this is an out dated approach - invisible graveyard like, for that we look in the code, or scan TODOs manually/automatically...
  4. Using the same ticket number - while possible, issue rises with the jira status dance, for the label status, switching its value unexpectedly and having reference to multiple PRs from a jira ticket - which is why a separate ticket felt like the default path. Additional reason is scoping sessions - similar to pull requests - to keep them small and targeted

This time, already spun the jira ticket. Future wise - hope that will adjust the workflow, to keep things leaner

@JetoPistola
JetoPistola merged commit ab7ce91 into main Aug 31, 2026
81 of 82 checks passed
@JetoPistola
JetoPistola deleted the danield/OPIK-7891-items-total-inflation branch August 31, 2026 14:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Backend baz: pending documentation Improvements or additions to documentation java Pull requests that update Java code 🟡 size/M tests Including test files, or tests related like configuration.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants