Skip to content

Commit c9eb439

Browse files
alexkuzmikclaude
andauthored
[NA] [SDK] fix: address review comments on dataset insert deduplication (#8115)
* [NA] [SDK] fix: address review comments on dataset insert deduplication Follow-up to #8113. - `_parallel_insert_supported` cached a probe that failed to reach the backend as `False`, which pinned the dataset to sequential uploads for the rest of the session. Only a conclusive answer is cached now; an unreachable backend is re-probed on the next insert, while a version that cannot be parsed still caches since it will not change. - Test suites listed by `get_test_suites` kept `_hashes_synced=True` with an empty hash set, so the first deduplicated insert compared against nothing and resubmitted items the suite already held. They now start unsynced, matching `get_datasets` and `get_test_suite`. - Reject a non-bool `deduplication`, so a truthy string or `None` cannot silently select the wrong duplicate-checking behaviour. - Note the resync cost of `deduplication=False` in the `insert` docstring. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(dataset): gate worker count in the insert funnel and test dedup by behaviour Addresses the review comments on the follow-up. - The parallel-upload version gate moves from `insert` into `__internal_api__insert_items_as_dataclasses__`, so a direct caller passing `num_threads > 1` can no longer reach the thread pool against a backend that races on batches sharing a batch_group_id. - The probe-recovery test now spies on `_send_batches` and asserts the worker count that actually reached the upload, instead of only counting probes. - The listed-suite regression test now inserts an item the suite already holds and asserts it is left out of the REST batch, rather than inspecting the private `_hashes_synced` flag. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(dataset): validate num_threads at the insert funnel and assert real uploads - The funnel compared `num_threads > 1` before checking its type, so a direct caller passing a non-orderable value got a `TypeError` from the gate rather than the `ValueError` naming the argument. It now validates both parameters it consumes. - The sequential-gate test spied on `_send_batches` without calling through, so it only covered argument plumbing. It now calls through and asserts every item still reached the backend exactly once. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(dataset): document the ValueError raised by insert's argument checks Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent b903f52 commit c9eb439

4 files changed

Lines changed: 301 additions & 22 deletions

File tree

sdks/python/src/opik/api_objects/dataset/dataset.py

Lines changed: 67 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,11 @@ def __init__(
353353
# one-shot sync on the first `insert()` instead of paying an N+1
354354
# sync at list time.
355355
self._hashes_synced: bool = True
356+
# None until the backend version has actually been determined. Only a
357+
# conclusive answer is stored, so a probe that failed to reach the
358+
# backend is retried instead of pinning this dataset to sequential
359+
# uploads for the rest of the session.
360+
self._parallel_insert_supported_cache: Optional[bool] = None
356361

357362
@classmethod
358363
def from_public(
@@ -611,44 +616,60 @@ def _insert_batch_with_retry(
611616
)
612617
LOGGER.debug("Successfully sent dataset items batch of size %d", len(batch))
613618

614-
@functools.cached_property
619+
@property
615620
def _parallel_insert_supported(self) -> bool:
616621
"""Whether the backend tolerates concurrent batches sharing a batch_group_id.
617622
618623
Older backends race on them, so parallelism is only safe from
619624
``constants.MIN_BACKEND_VERSION_FOR_PARALLEL_INSERT`` onwards. When the
620-
version cannot be determined at all — unreachable endpoint, non-semver
621-
build string — we report unsupported rather than risk the race.
622-
623-
Cached because the backend cannot change version mid-session, and
624-
parallel upload is the default: probing per ``insert`` would add a
625-
round trip to every call in a loop.
625+
version cannot be determined we report unsupported rather than risk the
626+
race.
627+
628+
The answer is cached because the backend cannot change version
629+
mid-session and parallel upload is the default: probing per ``insert``
630+
would add a round trip to every call in a loop. Only a conclusive
631+
answer is cached — an unreachable backend is re-probed on the next
632+
insert so parallel upload resumes once it recovers.
626633
"""
634+
if self._parallel_insert_supported_cache is not None:
635+
return self._parallel_insert_supported_cache
636+
627637
try:
628638
backend_version = self._rest_client.version()["version"]
639+
except Exception:
640+
LOGGER.warning(
641+
"Could not reach the Opik backend to determine its version, "
642+
"falling back to a sequential dataset upload for this insert.",
643+
exc_info=True,
644+
)
645+
return False
646+
647+
try:
629648
supported = (
630649
semantic_version.SemanticVersion.parse(backend_version)
631650
>= constants.MIN_BACKEND_VERSION_FOR_PARALLEL_INSERT
632651
)
633652
except Exception:
634653
LOGGER.warning(
635-
"Could not determine the Opik backend version, falling back to a "
654+
"Could not parse the Opik backend version %s, falling back to a "
636655
"sequential dataset upload. Parallel upload requires backend %s "
637656
"or newer.",
638-
constants.MIN_BACKEND_VERSION_FOR_PARALLEL_INSERT,
639-
exc_info=True,
640-
)
641-
return False
642-
643-
if not supported:
644-
LOGGER.warning(
645-
"Opik backend %s does not support parallel dataset upload, falling "
646-
"back to a sequential upload. Upgrade to backend %s or newer to use "
647-
"num_threads.",
648657
backend_version,
649658
constants.MIN_BACKEND_VERSION_FOR_PARALLEL_INSERT,
659+
exc_info=True,
650660
)
661+
supported = False
662+
else:
663+
if not supported:
664+
LOGGER.warning(
665+
"Opik backend %s does not support parallel dataset upload, "
666+
"falling back to a sequential upload. Upgrade to backend %s or "
667+
"newer to use num_threads.",
668+
backend_version,
669+
constants.MIN_BACKEND_VERSION_FOR_PARALLEL_INSERT,
670+
)
651671

672+
self._parallel_insert_supported_cache = supported
652673
return supported
653674

654675
def _send_batches(
@@ -718,6 +739,25 @@ def __internal_api__insert_items_as_dataclasses__(
718739
num_threads: int = 1,
719740
deduplication: bool = True,
720741
) -> None:
742+
# Validated here rather than in each public entry point: every insert
743+
# path funnels through this method. A truthy string or None would
744+
# otherwise silently pick the wrong duplicate-checking behaviour, and
745+
# a non-integer worker count would fail on the comparison below with a
746+
# TypeError instead of naming the offending argument.
747+
if not isinstance(deduplication, bool):
748+
raise ValueError("deduplication must be a bool")
749+
if isinstance(num_threads, bool) or not isinstance(num_threads, int):
750+
raise ValueError("num_threads must be a positive integer")
751+
if num_threads < 1:
752+
raise ValueError("num_threads must be a positive integer")
753+
754+
# Gated here rather than in `insert` so every caller of this funnel is
755+
# covered: older backends race on concurrent batches that share a
756+
# batch_group_id, and a direct caller asking for workers must not be
757+
# able to skip that check.
758+
if num_threads > 1 and not self._parallel_insert_supported:
759+
num_threads = 1
760+
721761
if deduplication:
722762
items_to_send = self._deduplicate(items)
723763
else:
@@ -756,22 +796,27 @@ def insert(
756796
deduplication: Whether to skip items whose content already exists
757797
in the dataset. Pass ``False`` to insert every item as-is
758798
without any duplicate checking, which is significantly faster
759-
on large datasets.
799+
on large datasets. The next insert that does deduplicate has to
800+
re-read the dataset's items to account for what was skipped.
760801
num_threads: Number of worker threads used to upload the item
761802
batches. Must be a positive integer, defaults to ``4``; pass
762803
``1`` to upload sequentially. All batches land in a single
763804
dataset version. If a batch fails the call raises, and the
764805
batches that already succeeded stay persisted. Older Opik
765806
backends do not support parallel upload and fall back to a
766807
sequential one.
808+
809+
Raises:
810+
ValueError: If ``num_threads`` is not a positive integer, or
811+
``deduplication`` is not a bool.
767812
"""
768813
if isinstance(num_threads, bool) or not isinstance(num_threads, int):
769814
raise ValueError("num_threads must be a positive integer")
770815
if num_threads < 1:
771816
raise ValueError("num_threads must be a positive integer")
772-
773-
if num_threads > 1 and not self._parallel_insert_supported:
774-
num_threads = 1
817+
# Checked here too so bad input raises before any item is converted.
818+
if not isinstance(deduplication, bool):
819+
raise ValueError("deduplication must be a bool")
775820

776821
dataset_items: List[dataset_item.DatasetItem] = [ # type: ignore
777822
(dataset_item.DatasetItem(**item) if isinstance(item, dict) else item)

sdks/python/src/opik/api_objects/dataset/rest_operations.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,10 @@ def get_test_suites(
284284
dataset_items_count=dataset_fern.dataset_items_count,
285285
client=client,
286286
)
287+
# This suite already holds items on the backend that we have not
288+
# hashed locally, so the first insert must sync before it can tell
289+
# a duplicate from a new item.
290+
suite_dataset.__internal_api__hashes_synced__ = False
287291

288292
suites.append(
289293
test_suite_module.TestSuite(

sdks/python/tests/unit/api_objects/dataset/test_dataset_client.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import pytest
77

88
from opik.api_objects import constants
9+
from opik.api_objects.dataset import dataset_item
910
from opik.api_objects.dataset.dataset import Dataset
1011

1112

@@ -551,6 +552,147 @@ def test_insert__version_endpoint_unreachable__uploads_sequentially(monkeypatch)
551552
), "A failing version probe must not break insert; it falls back to sequential"
552553

553554

555+
def test_insert__version_probe_recovers__parallel_upload_resumes(monkeypatch):
556+
"""A transient probe failure must not pin the dataset to sequential uploads."""
557+
_small_batches(monkeypatch, size=_GATE_BATCH_SIZE)
558+
mock_rest_client = Mock()
559+
mock_rest_client.version.side_effect = [
560+
ConnectionError("backend unreachable"),
561+
{"version": constants.MIN_BACKEND_VERSION_FOR_PARALLEL_INSERT},
562+
]
563+
dataset = Dataset(
564+
name="test_dataset",
565+
description="Test description",
566+
project_name="Test project",
567+
rest_client=mock_rest_client,
568+
)
569+
570+
# Spying on the upload layer keeps the assertion on the worker count that
571+
# actually reached it, so a gate that stops honouring the probe still fails
572+
# this test.
573+
workers_per_insert = []
574+
original_send = Dataset._send_batches
575+
576+
def spy_send_batches(self, batches, batch_group_id, num_threads):
577+
workers_per_insert.append(num_threads)
578+
return original_send(self, batches, batch_group_id, num_threads)
579+
580+
monkeypatch.setattr(Dataset, "_send_batches", spy_send_batches)
581+
582+
dataset.insert(_make_items(4), deduplication=False)
583+
dataset.insert(_make_items(4), deduplication=False)
584+
585+
assert mock_rest_client.version.call_count == 2, (
586+
"The failed probe must be retried rather than cached as unsupported"
587+
)
588+
assert workers_per_insert[0] == 1, (
589+
"While the backend is unreachable the upload must stay sequential"
590+
)
591+
assert workers_per_insert[1] > 1, (
592+
"Once the backend answers, the upload must go back to using workers"
593+
)
594+
595+
596+
def test_insert__unparseable_version__probed_once(monkeypatch):
597+
"""An unparseable version is a conclusive answer, so it must still cache."""
598+
_small_batches(monkeypatch, size=_GATE_BATCH_SIZE)
599+
mock_rest_client = _mock_rest_client("dev-local")
600+
dataset = Dataset(
601+
name="test_dataset",
602+
description="Test description",
603+
project_name="Test project",
604+
rest_client=mock_rest_client,
605+
)
606+
607+
for _ in range(3):
608+
dataset.insert(_make_items(4), deduplication=False)
609+
610+
assert mock_rest_client.version.call_count == 1, (
611+
"A version the SDK cannot parse will not change, so it must not be re-probed"
612+
)
613+
614+
615+
def test_internal_insert__old_backend__worker_count_still_gated(monkeypatch):
616+
"""The gate lives in the funnel, so a direct caller cannot skip it."""
617+
_small_batches(monkeypatch, size=_GATE_BATCH_SIZE)
618+
mock_rest_client = _mock_rest_client("2.2.7")
619+
dataset = Dataset(
620+
name="test_dataset",
621+
description="Test description",
622+
project_name="Test project",
623+
rest_client=mock_rest_client,
624+
)
625+
626+
# The spy calls through, so the upload still happens and the assertions
627+
# below cover the items actually reaching the backend, not just the
628+
# argument the gate computed.
629+
used_workers = []
630+
original_send = Dataset._send_batches
631+
632+
def spy_send_batches(self, batches, batch_group_id, num_threads):
633+
used_workers.append(num_threads)
634+
return original_send(self, batches, batch_group_id, num_threads)
635+
636+
monkeypatch.setattr(Dataset, "_send_batches", spy_send_batches)
637+
638+
dataset.__internal_api__insert_items_as_dataclasses__(
639+
[dataset_item.DatasetItem(**item) for item in _make_items(4)],
640+
num_threads=4,
641+
)
642+
643+
assert used_workers == [1], (
644+
"A backend that predates parallel insert must force a sequential upload "
645+
"even when the internal API is called directly"
646+
)
647+
648+
create_or_update = mock_rest_client.datasets.create_or_update_dataset_items
649+
submitted = sorted(
650+
item.data["input"]["i"]
651+
for call in create_or_update.call_args_list
652+
for item in call.kwargs["items"]
653+
)
654+
assert submitted == [0, 1, 2, 3], (
655+
"Forcing a sequential upload must still deliver every item exactly once"
656+
)
657+
658+
659+
@pytest.mark.parametrize("bad_value", [0, -1, 1.5, "2", True, None])
660+
def test_internal_insert__invalid_num_threads__raises_value_error(bad_value):
661+
"""Direct callers get the named ValueError, not a TypeError from the gate."""
662+
mock_rest_client = Mock()
663+
dataset = Dataset(
664+
name="test_dataset",
665+
description="Test description",
666+
project_name="Test project",
667+
rest_client=mock_rest_client,
668+
)
669+
670+
with pytest.raises(ValueError, match="num_threads must be a positive integer"):
671+
dataset.__internal_api__insert_items_as_dataclasses__(
672+
[dataset_item.DatasetItem(**item) for item in _make_items(2)],
673+
num_threads=bad_value,
674+
)
675+
676+
mock_rest_client.datasets.create_or_update_dataset_items.assert_not_called()
677+
678+
679+
@pytest.mark.parametrize("bad_value", ["false", 0, 1, None, "", []])
680+
def test_insert__non_bool_deduplication__raises_before_any_request(bad_value):
681+
mock_rest_client = Mock()
682+
dataset = Dataset(
683+
name="test_dataset",
684+
description="Test description",
685+
project_name="Test project",
686+
rest_client=mock_rest_client,
687+
)
688+
689+
with pytest.raises(ValueError, match="deduplication must be a bool"):
690+
dataset.insert(_make_items(3), deduplication=bad_value)
691+
692+
mock_rest_client.datasets.create_or_update_dataset_items.assert_not_called()
693+
mock_rest_client.version.assert_not_called()
694+
695+
554696
def test_insert__sequential__uploads_sequentially_without_probing_version(monkeypatch):
555697
mock_rest_client = _mock_rest_client()
556698

0 commit comments

Comments
 (0)