Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 50 additions & 19 deletions sdks/python/src/opik/api_objects/dataset/dataset.py
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,11 @@ def __init__(
# one-shot sync on the first `insert()` instead of paying an N+1
# sync at list time.
self._hashes_synced: bool = True
# None until the backend version has actually been determined. Only a
# conclusive answer is stored, so a probe that failed to reach the
# backend is retried instead of pinning this dataset to sequential
# uploads for the rest of the session.
self._parallel_insert_supported_cache: Optional[bool] = None

@classmethod
def from_public(
Expand Down Expand Up @@ -611,44 +616,60 @@ def _insert_batch_with_retry(
)
LOGGER.debug("Successfully sent dataset items batch of size %d", len(batch))

@functools.cached_property
@property
def _parallel_insert_supported(self) -> bool:
"""Whether the backend tolerates concurrent batches sharing a batch_group_id.

Older backends race on them, so parallelism is only safe from
``constants.MIN_BACKEND_VERSION_FOR_PARALLEL_INSERT`` onwards. When the
version cannot be determined at all — unreachable endpoint, non-semver
build string — we report unsupported rather than risk the race.

Cached because the backend cannot change version mid-session, and
parallel upload is the default: probing per ``insert`` would add a
round trip to every call in a loop.
version cannot be determined we report unsupported rather than risk the
race.

The answer is cached because the backend cannot change version
mid-session and parallel upload is the default: probing per ``insert``
would add a round trip to every call in a loop. Only a conclusive
answer is cached — an unreachable backend is re-probed on the next
insert so parallel upload resumes once it recovers.
"""
if self._parallel_insert_supported_cache is not None:
return self._parallel_insert_supported_cache

try:
backend_version = self._rest_client.version()["version"]
except Exception:
LOGGER.warning(
"Could not reach the Opik backend to determine its version, "
"falling back to a sequential dataset upload for this insert.",
exc_info=True,
)
return False

try:
supported = (
semantic_version.SemanticVersion.parse(backend_version)
>= constants.MIN_BACKEND_VERSION_FOR_PARALLEL_INSERT
)
except Exception:
LOGGER.warning(
"Could not determine the Opik backend version, falling back to a "
"Could not parse the Opik backend version %s, falling back to a "
"sequential dataset upload. Parallel upload requires backend %s "
"or newer.",
constants.MIN_BACKEND_VERSION_FOR_PARALLEL_INSERT,
exc_info=True,
)
return False

if not supported:
LOGGER.warning(
"Opik backend %s does not support parallel dataset upload, falling "
"back to a sequential upload. Upgrade to backend %s or newer to use "
"num_threads.",
backend_version,
constants.MIN_BACKEND_VERSION_FOR_PARALLEL_INSERT,
exc_info=True,
)
supported = False
else:
if not supported:
LOGGER.warning(
"Opik backend %s does not support parallel dataset upload, "
"falling back to a sequential upload. Upgrade to backend %s or "
"newer to use num_threads.",
backend_version,
constants.MIN_BACKEND_VERSION_FOR_PARALLEL_INSERT,
)

self._parallel_insert_supported_cache = supported
return supported

def _send_batches(
Expand Down Expand Up @@ -718,6 +739,12 @@ def __internal_api__insert_items_as_dataclasses__(
num_threads: int = 1,
deduplication: bool = True,
) -> None:
# Validated here rather than in each public entry point: every insert
# path funnels through this method. A truthy string or None would
# otherwise silently pick the wrong duplicate-checking behaviour.
if not isinstance(deduplication, bool):
raise ValueError("deduplication must be a bool")
Comment on lines +747 to +748

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Internal inserts bypass backend safety gate

The internal insertion funnel validates deduplication but forwards num_threads to _send_batches without applying the _parallel_insert_supported gate, so direct calls with num_threads > 1 use ThreadPoolExecutor against old backends instead of the fallback in insert() — should we apply the same worker-count compatibility check here?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
`sdks/python/src/opik/api_objects/dataset/dataset.py` around lines 745-746, update
`__internal_api__insert_items_as_dataclasses__` so it applies the same
`_parallel_insert_supported` compatibility gate as `insert()`. Before forwarding
`num_threads` to `_send_batches`, reduce values greater than 1 to 1 when the backend
does not support parallel insertion, ensuring direct internal callers cannot bypass the
sequential-upload fallback.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Commit 00c79c8 addressed this comment by applying the _parallel_insert_supported gate inside the internal insertion funnel before forwarding num_threads to batch sending. Unsupported backends now force worker count to 1, including for direct internal callers.


if deduplication:
items_to_send = self._deduplicate(items)
else:
Expand Down Expand Up @@ -756,7 +783,8 @@ def insert(
deduplication: Whether to skip items whose content already exists
in the dataset. Pass ``False`` to insert every item as-is
without any duplicate checking, which is significantly faster
on large datasets.
on large datasets. The next insert that does deduplicate has to
re-read the dataset's items to account for what was skipped.
num_threads: Number of worker threads used to upload the item
batches. Must be a positive integer, defaults to ``4``; pass
``1`` to upload sequentially. All batches land in a single
Expand All @@ -769,6 +797,9 @@ def insert(
raise ValueError("num_threads must be a positive integer")
if num_threads < 1:
raise ValueError("num_threads must be a positive integer")
# Checked before the version probe below so bad input costs no request.
if not isinstance(deduplication, bool):
raise ValueError("deduplication must be a bool")

if num_threads > 1 and not self._parallel_insert_supported:
num_threads = 1
Expand Down
4 changes: 4 additions & 0 deletions sdks/python/src/opik/api_objects/dataset/rest_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,10 @@ def get_test_suites(
dataset_items_count=dataset_fern.dataset_items_count,
client=client,
)
# This suite already holds items on the backend that we have not
# hashed locally, so the first insert must sync before it can tell
# a duplicate from a new item.
suite_dataset.__internal_api__hashes_synced__ = False
Comment on lines +287 to +290

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Listed suites lose owning project scope

In get_test_suites, the lazy-sync suite_dataset keeps the caller's project_name even when dataset_fern resolves via project_id, so workspace-wide or fallback listings can stream or upsert same-named suites into the wrong dataset — should we resolve and retain the returned suite's owning project name before enabling __internal_api__hashes_synced__, as Dataset.from_public already does?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
`sdks/python/src/opik/api_objects/dataset/rest_operations.py` around lines 287-290 in
`get_test_suites`, fix the lazy-sync setup so each listed suite retains its actual
owning project rather than the caller’s `project_name`. Resolve the project name from
`dataset_fern.project_id` (as `Dataset.from_public` does) and pass that resolved name
when constructing `suite_dataset` before enabling `__internal_api__hashes_synced__`,
ensuring streaming and upserts target the correct dataset.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Not taking this one here, because it is pre-existing rather than introduced by this change.

get_datasets uses the identical pattern — it passes the caller's project_name straight into the Dataset while deriving project_id from that same name, and it sets __internal_api__hashes_synced__ = False too. So the scope behaviour of a listed suite is now exactly the scope behaviour of a listed dataset; before this PR the suite was inconsistent with it in the other direction (it never synced at all, which is the resubmission bug this PR fixes).

Resolving the owning project per listed item, the way from_public does, means a get_project_by_id call for every dataset in the listing. That is precisely the N+1 the lazy-sync design was built to avoid, so it is a deliberate trade-off to change, not a local fix — and it would need to change get_datasets at the same time to keep the two consistent. Worth doing as its own change if we want listings to be project-accurate.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for the context—this is an intentional lazy-sync trade-off shared with get_datasets, rather than a regression in this PR. I’ll save this to memory once the PR is merged.

Comment on lines +287 to +290

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Missing listed-suite deduplication coverage

The regression test only checks the private __internal_api__hashes_synced__ flag, so it doesn't verify that a duplicate item is actually omitted from the REST batch after retrieving a populated suite via get_test_suites() — should we extend it, or add an integration/E2E test, to assert the batch excludes the duplicate or the backend item count stays unchanged?

Severity

Want Baz to fix this for you? Activate Fixer You can also update your AI coding guidelines based on this comment by apply pr to [branch name]

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
`sdks/python/src/opik/api_objects/dataset/rest_operations.py` around lines 287-290,
extend the tests for `get_test_suites()` to cover the cross-file synchronization
behavior introduced by `__internal_api__hashes_synced__ = False`. Populate a suite,
retrieve it through `get_test_suites()`, insert an identical item with deduplication
enabled, and assert that the REST batch omits the duplicate or that the backend item
count remains unchanged; do not limit the test to checking the private flag.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Commit 00c79c8 addressed this comment by adding an integration-style regression test that retrieves a populated suite, inserts a duplicate and a new item, and asserts only the new item is submitted.


suites.append(
test_suite_module.TestSuite(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,68 @@ def test_insert__version_endpoint_unreachable__uploads_sequentially(monkeypatch)
), "A failing version probe must not break insert; it falls back to sequential"


def test_insert__version_probe_recovers__parallel_upload_resumes(monkeypatch):
"""A transient probe failure must not pin the dataset to sequential uploads."""
_small_batches(monkeypatch, size=_GATE_BATCH_SIZE)
mock_rest_client = Mock()
mock_rest_client.version.side_effect = [
ConnectionError("backend unreachable"),
{"version": constants.MIN_BACKEND_VERSION_FOR_PARALLEL_INSERT},
]
dataset = Dataset(
name="test_dataset",
description="Test description",
project_name="Test project",
rest_client=mock_rest_client,
)

dataset.insert(_make_items(4), deduplication=False)
dataset.insert(_make_items(4), deduplication=False)

assert mock_rest_client.version.call_count == 2, (
"The failed probe must be retried rather than cached as unsupported"
)
assert dataset._parallel_insert_supported, (
"Once the backend answers, parallel upload must be available again"
)
Comment on lines +582 to +593

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Parallel upload regression goes undetected

test_insert__version_probe_recovers__parallel_upload_resumes only checks the probe count and _parallel_insert_supported, so a sequential second insert would still pass — should we add a barrier or active-worker counter to the batch upload mock to assert overlap while retaining the probe-retry assertions?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
`sdks/python/tests/unit/api_objects/dataset/test_dataset_client.py` around lines
569-577, strengthen `test_insert__version_probe_recovers__parallel_upload_resumes` so it
verifies that the second insert actually uses parallel/concurrent batch uploading, not
merely that the `_parallel_insert_supported` cache flag is set and the probe call count
is correct. Instrument the batch upload mock with a barrier, event, or deterministic
active-concurrent-call counter, and assert that overlap occurs among batch uploads
during the second insert, while retaining the existing version-probe retry assertions.
Alternatively, if adding this determinism proves too complex, consider renaming the test
to reflect that it only covers probe-cache recovery.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Addressed in 00c79c8. test_insert__version_probe_recovers__parallel_upload_resumes now installs a spy on _send_batches that calls through to the real implementation and records the worker count each insert actually used, then asserts [0] == 1 (sequential while the backend is unreachable) and [1] > 1 (workers again once it answers). A second insert that stayed sequential now fails the test, which was the gap you flagged.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Addressed. The spy now verifies the effective worker count reaching _send_batches: the first insert uses 1, while the recovered second insert uses multiple workers, and _send_batches fans those workers out through ThreadPoolExecutor.



def test_insert__unparseable_version__probed_once(monkeypatch):
"""An unparseable version is a conclusive answer, so it must still cache."""
_small_batches(monkeypatch, size=_GATE_BATCH_SIZE)
mock_rest_client = _mock_rest_client("dev-local")
dataset = Dataset(
name="test_dataset",
description="Test description",
project_name="Test project",
rest_client=mock_rest_client,
)

for _ in range(3):
dataset.insert(_make_items(4), deduplication=False)

assert mock_rest_client.version.call_count == 1, (
"A version the SDK cannot parse will not change, so it must not be re-probed"
)


@pytest.mark.parametrize("bad_value", ["false", 0, 1, None, "", []])
def test_insert__non_bool_deduplication__raises_before_any_request(bad_value):
mock_rest_client = Mock()
dataset = Dataset(
name="test_dataset",
description="Test description",
project_name="Test project",
rest_client=mock_rest_client,
)

with pytest.raises(ValueError, match="deduplication must be a bool"):
dataset.insert(_make_items(3), deduplication=bad_value)

mock_rest_client.datasets.create_or_update_dataset_items.assert_not_called()
mock_rest_client.version.assert_not_called()


def test_insert__sequential__uploads_sequentially_without_probing_version(monkeypatch):
mock_rest_client = _mock_rest_client()

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
from unittest.mock import Mock

from opik.api_objects.dataset import rest_operations


def _find_datasets_returning(*pages) -> Mock:
"""A rest client whose find_datasets yields the given pages, then an empty one.

The pagination loop only stops on an empty page, so the trailing empty page
is what keeps these tests from spinning forever.
"""
mock_rest_client = Mock()
mock_rest_client.datasets.find_datasets.side_effect = [
Mock(content=list(page)) for page in (*pages, ())
]
return mock_rest_client


def _backend_dataset(name: str, type_: str, items_total: int) -> Mock:
dataset_fern = Mock()
dataset_fern.configure_mock(
name=name,
description="",
type=type_,
dataset_items_count=items_total,
)
return dataset_fern


def test_get_test_suites__suite_holds_backend_items__first_insert_syncs_hashes():
"""A listed suite must not treat its empty local hash set as authoritative.

Otherwise the first deduplicated insert compares against nothing, decides
every item is new, and resubmits items the suite already holds.
"""
mock_rest_client = _find_datasets_returning(
[_backend_dataset("my-suite", "evaluation_suite", items_total=25)]
)

suites = rest_operations.get_test_suites(
project_name="Test project",
rest_client=mock_rest_client,
)

assert len(suites) == 1
assert not suites[0]._dataset.__internal_api__hashes_synced__, (
"A suite listed from the backend has items we have not hashed locally, "
"so the first insert must sync before deduplicating"
)
Comment on lines +42 to +72

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suite deduplication behavior remains untested

This test only inspects private _hashes_synced and never performs the first deduplicating insert, so it doesn't verify that listed suites re-read backend items and avoid duplicate submissions — should we run that insert against a mocked backend and assert the item-fetch/sync request and submitted items?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
`sdks/python/tests/unit/api_objects/dataset/test_rest_operations.py` around lines 40-49,
update `test_get_test_suites__suite_holds_backend_items__first_insert_syncs_hashes` so
it performs the suite’s first deduplicating insert instead of only inspecting the
private `_hashes_synced` flag. Configure the mocked backend item-fetch and insert
endpoints, execute an insert containing existing and new items, and assert that backend
items are fetched/synchronized and only the new items are submitted.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Commit 00c79c8 addressed this comment by exercising the first deduplicating insert with existing and new items, then asserting only the new item is submitted. It does not explicitly assert the fetch call itself.



def test_get_test_suites__non_suite_datasets__are_skipped():
mock_rest_client = _find_datasets_returning(
[
_backend_dataset("plain-dataset", "dataset", items_total=3),
_backend_dataset("my-suite", "evaluation_suite", items_total=3),
]
)

suites = rest_operations.get_test_suites(
project_name="Test project",
rest_client=mock_rest_client,
)

assert [suite.name for suite in suites] == ["my-suite"]
Loading