Repository navigation
Description
Description
Dataset.delete() removes each deleted id from _id_to_hash and immediately discards its content hash from _hashes. A content hash is not unique per id: two items can share it, for example after dataset.insert([item, item], deduplication=False), which stores two items with the same content and different ids. Deleting one of them drops the hash while the surviving id still maps to it, so the next deduplicated insert no longer sees the content and uploads a duplicate.
The same block also leaves _hashes_synced = True when a later batch fails after earlier batches were deleted, even though the backend state may no longer match the cache.
Expected: a hash should be dropped only when no surviving item still matches it, and a failed delete should mark the cache for re-sync like a failed insert. Actual: deleting one of several same-content items drops the shared hash, and the next insert duplicates the content.
SDK Version
2.2.143 (main @ cc65247, 2026-11-05).
Steps to Repro
With a live Opik backend:
dataset = client.create_dataset(name="dedup-delete-repro")
# Two items with identical content, different ids, deliberately bypassing dedup.
dataset.insert(
[{"input": {"key": "same"}}, {"input": {"key": "same"}}],
deduplication=False,
)
items = dataset.get_items()
assert len(items) == 2
# A deduplicated insert syncs the hashes and skips the content.
dataset.insert([{"input": {"key": "same"}}], deduplication=True)
# Delete one of the two items that share the content hash.
dataset.delete([items[0].id])
# The other item still stores the content, so this must be skipped.
dataset.insert([{"input": {"key": "same"}}], deduplication=True)Expected: the dataset still has 2 items after the last insert.
Actual: it has 3 — the last insert uploaded a duplicate because the shared hash was dropped from _hashes when items[0] was deleted.
Unit-level evidence on cc65247: with the SDK's existing _StoringCapture/_serve_stored_items fakes, the scenario records 2 upload requests instead of 1; the delete of one shared id leaves _hashes empty while _id_to_hash still contains the surviving id.
Which part of Opik is this about?
SDK: datasets (insert deduplication and local hash cache).
Suggested fix
After the batches complete, rebuild _hashes from the surviving _id_to_hash values instead of discarding each deleted hash unconditionally:
completed = False
try:
for batch in batches:
self._delete_batch_with_retry(batch, batch_group_id=batch_group_id)
for item_id in batch:
self._id_to_hash.pop(item_id, None)
completed = True
finally:
self._dataset_items_count = None
if completed:
self._hashes = set(self._id_to_hash.values())
else:
self._hashes_synced = FalseThe rebuild is O(number of cached ids) per delete() call; a refcounted hash cache would avoid that if the maintainers prefer. The _hashes_synced = False on the failure path covers the case where a batch was applied by the backend even though the call raised.
Additional context
Searched open/closed issues and PRs for dataset delete + dedup/hash / re-sync; the closest is #9376/#9393 (failed insert leaves the cache stale), which is the insert-side counterpart of this bug. No open PR changes dataset.py's delete path.
Activity
github-actions commentedon Nov 5, 2026
Hi, I'm Scout 🦉, the comet-ml/opik repository agent. Let me look into this.
Solution / Workaround
No existing workaround found in the issue tracker. The reporter has already identified the root cause precisely and provided a concrete fix. The closest related issue is #9376 (now closed), which addressed the insert-side counterpart: a failed
insert()leaving stale hashes in the cache.Code Investigation
The bug lives in
sdks/python/src/opik/api_objects/dataset/dataset.py, in thedelete()method's loop that manages the in-memory hash cache.The core problem is that
_hashesis a set of hash values while_id_to_hashis a dict mapping item id → hash. The hash-to-id relationship is not injective: two items can share the same hash (this is intentional and expected —deduplication=Falseexplicitly allows it). The current delete loop does something like:This incorrectly removes
hash_valfrom_hashesas soon as any id that maps to it is deleted — even when other ids in_id_to_hashstill reference the same hash. The next deduplicatedinsert()call then no longer finds the hash in_hashes, considers the content new, and uploads a duplicate.The second issue is on the failure path:
_hashes_syncedis left asTrueeven when a batch fails mid-loop, leaving the cache in an inconsistent state (insert-side analogue was fixed in #9376/#9393).Test coverage:
sdks/python/tests/unit/api_objects/dataset/test_dataset_client.pyhas good coverage of insert deduplication scenarios but no test exists for the delete-then-insert scenario described here. Theupload_capture.pyfake infrastructure is well-suited to add such a test without a live backend.Next Steps
The reporter's suggested fix is correct and minimal. Apply it to the delete batch loop in
dataset.py:The key insight: rebuilding
_hashes = set(self._id_to_hash.values())after all pops is correct becauseset()deduplicates, so a hash shared by multiple surviving ids will appear exactly once — and a hash only disappears from_hasheswhen every id that referenced it has been deleted.A test should be added to
test_dataset_client.pyusing the existingUploadCapture/make_datasethelpers, verifying:insert([item, item], deduplication=False)+ delete of one id, a subsequent deduplicated insert of the same content is skipped (no extra upload request)._hashes_syncedisFalse.Feel free to open a pull request with this fix — the change is straightforward!
Was this helpful? React to this comment with 👍 or 👎 to rate my response.
View Scout trace in Opik
surlymochan commentedon Nov 6, 2026
Confirmed by reading the code (local checkout at
68c0725, 2026-11-05):sdks/python/src/opik/api_objects/dataset/dataset.py:1264-1267indelete(): for each deleted id it doesself._hashes.discard(hash)unconditionally — no check whether another surviving id in_id_to_hashstill maps to that hash. So deleting one of two same-content items drops the shared hash and the next deduplicated insert re-uploads it, exactly as reported.finallyblock only clears_dataset_items_countand never touches_hashes_synced, unlike the insert path which sets_hashes_synced = Falseon failure (lines ~959, ~1030) — so a mid-batch delete failure leaves the cache marked synced.The suggested fix (rebuild
_hashesfrom the surviving_id_to_hashvalues after successful batches, and mark unsynced on failure) lines up with the code as written.