Skip to content

Commit bff15c3

Browse files
alexkuzmikclaude
andcommitted
fix(dataset): pin reads to one version and stop blocking on early exit
Pages are addressed by offset and the backend sorts newest id first, so an item inserted mid-read landed at offset 0 and shifted every unfetched page: the read returned one item twice and skipped another. The cursor stream got snapshot semantics for free from its `id < last_retrieved_id` seek, so this was a regression against it, and it landed on callers that match items by exact equality. Every page of a read now carries one version hash, resolved once. Backends without dataset versioning fall back to a live read, which is documented on stream_items(). The thread pool also moves out of a `with` block: the yields happen inside its scope, so closing the generator ran shutdown(wait=True) and blocked the caller until every outstanding page returned -- at GC time, on an unrelated line. It now shuts down with cancel_futures=True and wait=False. Adds get_items(chunk_size=...) so the page size is tunable from the simple API as well. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 8705389 commit bff15c3

3 files changed

Lines changed: 211 additions & 26 deletions

File tree

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

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ def get_items(
122122
nb_samples: Optional[int] = None,
123123
filter_string: Optional[str] = None,
124124
num_threads: int = constants.DATASET_ITEMS_READ_NUM_THREADS,
125+
chunk_size: int = constants.DATASET_STREAM_BATCH_SIZE,
125126
) -> List[Dict[str, Any]]:
126127
"""
127128
Retrieve dataset items as a list of dictionaries.
@@ -137,6 +138,10 @@ def get_items(
137138
``constants.DATASET_ITEMS_READ_MAX_THREADS``. Use
138139
:meth:`stream_items` instead when the dataset is too large to
139140
hold in memory all at once.
141+
chunk_size: Number of items fetched per request. See
142+
:meth:`stream_items` for how to pick it; the whole result is
143+
materialized either way, so this only trades request count
144+
against per-request size.
140145
filter_string: Optional OQL filter string to filter dataset items.
141146
Supports filtering by tags, data fields, metadata, etc.
142147
@@ -156,12 +161,15 @@ def get_items(
156161
A list of dictionaries representing the dataset items.
157162
158163
Raises:
159-
ValueError: If ``num_threads`` is not a positive integer, or
164+
ValueError: If ``num_threads`` is not a positive integer, if
165+
``chunk_size`` is not a positive integer or exceeds
166+
``constants.DATASET_ITEMS_READ_MAX_CHUNK_SIZE``, or if
160167
``nb_samples`` is not a positive integer.
161168
"""
162169
return [
163170
item
164171
for chunk in self.stream_items(
172+
chunk_size=chunk_size,
165173
filter_string=filter_string,
166174
nb_samples=nb_samples,
167175
num_threads=num_threads,
@@ -189,6 +197,13 @@ def stream_items(
189197
Items have exactly the shape :meth:`get_items` returns: the item's
190198
data plus its ``id``.
191199
200+
The read is pinned to a single dataset version, so items inserted or
201+
deleted while it is in progress do not affect it. On backends where
202+
dataset versioning is unavailable there is no version to pin to and the
203+
live state is read instead; a concurrent insert can then shift the
204+
remaining pages, returning one item twice and skipping another. Read a
205+
:class:`DatasetVersion` explicitly if you need that guarantee there.
206+
192207
Args:
193208
chunk_size: Number of items per chunk, defaulting to and capped at
194209
the same batch size the typed item stream reads with
@@ -1138,9 +1153,37 @@ def __internal_api__stream_item_chunks__(
11381153
num_threads=num_threads,
11391154
nb_samples=nb_samples,
11401155
filter_string=filter_string,
1141-
dataset_version=None,
1156+
dataset_version=self._resolve_read_version(),
11421157
)
11431158

1159+
def _resolve_read_version(self) -> Optional[str]:
1160+
"""The version hash every page of one read is pinned to, if there is one.
1161+
1162+
Pages are addressed by offset, and the backend sorts newest id first, so
1163+
an item inserted mid-read lands at offset 0 and shifts every page that
1164+
has not been fetched yet -- returning one item twice and skipping
1165+
another. Reading a single version instead makes the whole read a
1166+
snapshot, which is what the cursor-based stream got for free from its
1167+
``id < last_retrieved_id`` seek.
1168+
1169+
Returns None when the backend has no version to pin to (versioning
1170+
disabled, or a dataset with no versions yet); the read then falls back
1171+
to the live state and stays vulnerable to that shift, which is called
1172+
out on :meth:`stream_items`.
1173+
"""
1174+
version_info = self.get_version_info()
1175+
version_hash = version_info.version_hash if version_info else None
1176+
1177+
if version_hash is None:
1178+
LOGGER.debug(
1179+
"No dataset version to pin the read of dataset %s to; reading "
1180+
"the live state, which may return an item twice or skip one if "
1181+
"items are inserted or deleted while the read is in progress.",
1182+
self._name,
1183+
)
1184+
1185+
return version_hash
1186+
11441187
def insert_from_json(
11451188
self,
11461189
json_array: str,

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

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -117,13 +117,19 @@ def _read_pages(
117117
worker_count,
118118
)
119119

120-
with futures.ThreadPoolExecutor(
120+
# Deliberately not a `with` block: its __exit__ is shutdown(wait=True), and
121+
# because the yields below happen inside it, closing the generator -- an
122+
# early `break`, or garbage collection -- would block the caller until every
123+
# outstanding page came back. At a 2000-item page that is a visible stall on
124+
# what looks like a plain `break`, and at GC time it would surface on an
125+
# unrelated line.
126+
pool = futures.ThreadPoolExecutor(
121127
max_workers=worker_count, thread_name_prefix="opik_dataset_items_read"
122-
) as pool:
128+
)
129+
try:
123130
# Two pages in flight per worker: enough that a worker always has the
124131
# next page waiting, while keeping the reader's memory bounded by the
125-
# look-ahead rather than by the size of the dataset. A consumer that
126-
# stops iterating leaves at most this many requests to drain.
132+
# look-ahead rather than by the size of the dataset.
127133
in_flight: Deque[futures.Future] = collections.deque(
128134
pool.submit(fetch_page, page)
129135
for page in itertools.islice(remaining_pages, worker_count * 2)
@@ -137,6 +143,11 @@ def _read_pages(
137143
in_flight.append(pool.submit(fetch_page, next_page))
138144

139145
yield _page_items(page)
146+
finally:
147+
# Drops the pages that haven't started and doesn't join the ones that
148+
# have, so abandoning the iterator returns immediately. On the normal
149+
# path there is nothing left in flight, so this is a no-op.
150+
pool.shutdown(wait=False, cancel_futures=True)
140151

141152

142153
def _build_page_fetcher(

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

Lines changed: 151 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import json
44
import threading
5+
import time
56
from typing import Any, Dict, List, Optional
67
from unittest.mock import Mock, patch
78

@@ -81,17 +82,36 @@ def _rest_items(count: int) -> List[Dict[str, Any]]:
8182
]
8283

8384

84-
def _build_dataset(endpoint: Optional[FakeItemsEndpoint]) -> Dataset:
85+
def _mock_rest_client(endpoint=None, version_hash: Optional[str] = None) -> Mock:
86+
"""A rest client whose dataset-id and version lookups are both controlled.
87+
88+
``version_hash=None`` models a backend with no version to pin a read to,
89+
which is the default so the assertions about the ``version`` query
90+
parameter stay meaningful.
91+
"""
8592
mock_rest_client = Mock()
8693
mock_rest_client.datasets.get_dataset_by_identifier.return_value.id = DATASET_ID
94+
95+
versions_page = Mock()
96+
versions_page.content = (
97+
[Mock(version_hash=version_hash)] if version_hash is not None else []
98+
)
99+
mock_rest_client.datasets.list_dataset_versions.return_value = versions_page
100+
87101
if endpoint is not None:
88102
mock_rest_client._client_wrapper.httpx_client.request.side_effect = endpoint
89103

104+
return mock_rest_client
105+
106+
107+
def _build_dataset(
108+
endpoint: Optional[FakeItemsEndpoint], version_hash: Optional[str] = None
109+
) -> Dataset:
90110
return Dataset(
91111
name="test-dataset",
92112
description=None,
93113
project_name=None,
94-
rest_client=mock_rest_client,
114+
rest_client=_mock_rest_client(endpoint, version_hash),
95115
)
96116

97117

@@ -466,33 +486,25 @@ def test_stream_items__malformed_total__raises_instead_of_truncating(total):
466486
if total is not None:
467487
body["total"] = total
468488

469-
mock_rest_client = Mock()
470-
mock_rest_client.datasets.get_dataset_by_identifier.return_value.id = DATASET_ID
471-
mock_rest_client._client_wrapper.httpx_client.request.side_effect = (
472-
_endpoint_returning(body)
473-
)
474489
dataset = Dataset(
475490
name="test-dataset",
476491
description=None,
477492
project_name=None,
478-
rest_client=mock_rest_client,
493+
rest_client=_mock_rest_client(_endpoint_returning(body)),
479494
)
480495

481496
with pytest.raises(exceptions.OpikException, match="Malformed response"):
482497
list(dataset.stream_items())
483498

484499

485500
def test_stream_items__non_list_content__raises():
486-
mock_rest_client = Mock()
487-
mock_rest_client.datasets.get_dataset_by_identifier.return_value.id = DATASET_ID
488-
mock_rest_client._client_wrapper.httpx_client.request.side_effect = (
489-
_endpoint_returning({"total": 2, "content": {"not": "a list"}})
490-
)
491501
dataset = Dataset(
492502
name="test-dataset",
493503
description=None,
494504
project_name=None,
495-
rest_client=mock_rest_client,
505+
rest_client=_mock_rest_client(
506+
_endpoint_returning({"total": 2, "content": {"not": "a list"}})
507+
),
496508
)
497509

498510
with pytest.raises(exceptions.OpikException, match="Malformed response"):
@@ -501,16 +513,11 @@ def test_stream_items__non_list_content__raises():
501513

502514
def test_stream_items__total_zero_with_empty_content__reads_nothing():
503515
"""A genuinely empty dataset is not malformed."""
504-
mock_rest_client = Mock()
505-
mock_rest_client.datasets.get_dataset_by_identifier.return_value.id = DATASET_ID
506-
mock_rest_client._client_wrapper.httpx_client.request.side_effect = (
507-
_endpoint_returning({"total": 0, "content": []})
508-
)
509516
dataset = Dataset(
510517
name="test-dataset",
511518
description=None,
512519
project_name=None,
513-
rest_client=mock_rest_client,
520+
rest_client=_mock_rest_client(_endpoint_returning({"total": 0, "content": []})),
514521
)
515522

516523
assert list(dataset.stream_items()) == []
@@ -561,3 +568,127 @@ def test_get_items__chunk_size_cap_applies_through_get_items():
561568
call["params"]["size"] <= constants.DATASET_ITEMS_READ_MAX_CHUNK_SIZE
562569
for call in endpoint.calls
563570
)
571+
572+
573+
def test_stream_items__version_available__every_page_pinned_to_it():
574+
"""Pages are addressed by offset, so they must all read one version --
575+
otherwise an insert landing at offset 0 shifts the unfetched pages."""
576+
endpoint = FakeItemsEndpoint(_rest_items(25))
577+
dataset = _build_dataset(endpoint, version_hash="v-hash-abc")
578+
579+
list(dataset.stream_items(chunk_size=10, num_threads=4))
580+
581+
assert len(endpoint.calls) == 3
582+
assert {call["params"]["version"] for call in endpoint.calls} == {"v-hash-abc"}
583+
584+
585+
def test_stream_items__no_version_available__reads_the_live_state():
586+
endpoint = FakeItemsEndpoint(_rest_items(25))
587+
dataset = _build_dataset(endpoint, version_hash=None)
588+
589+
list(dataset.stream_items(chunk_size=10, num_threads=4))
590+
591+
assert {call["params"]["version"] for call in endpoint.calls} == {None}
592+
593+
594+
def test_stream_items__version_resolved_once__not_per_page():
595+
endpoint = FakeItemsEndpoint(_rest_items(50))
596+
dataset = _build_dataset(endpoint, version_hash="v-hash-abc")
597+
598+
list(dataset.stream_items(chunk_size=10, num_threads=4))
599+
600+
assert dataset._rest_client.datasets.list_dataset_versions.call_count == 1
601+
602+
603+
def test_stream_items__version_lookup_deferred_until_iteration():
604+
endpoint = FakeItemsEndpoint(_rest_items(10))
605+
dataset = _build_dataset(endpoint, version_hash="v-hash-abc")
606+
versions = dataset._rest_client.datasets.list_dataset_versions
607+
608+
stream = dataset.stream_items()
609+
610+
assert versions.call_count == 0
611+
next(iter(stream))
612+
assert versions.call_count == 1
613+
614+
615+
def test_get_items__pins_the_read_to_a_version_too():
616+
endpoint = FakeItemsEndpoint(_rest_items(25))
617+
dataset = _build_dataset(endpoint, version_hash="v-hash-abc")
618+
619+
dataset.get_items(chunk_size=10)
620+
621+
assert {call["params"]["version"] for call in endpoint.calls} == {"v-hash-abc"}
622+
623+
624+
class ShiftingItemsEndpoint(FakeItemsEndpoint):
625+
"""Simulates an insert landing between page 1 and page 2.
626+
627+
Ids sort newest-first, so a new item takes offset 0 and pushes every later
628+
item one slot further down -- the exact shift that makes an offset-paged
629+
read of a live dataset return one item twice and skip another.
630+
"""
631+
632+
def __call__(self, path, *, method, params):
633+
response = super().__call__(path, method=method, params=params)
634+
if params["page"] == 1 and params.get("version") is None:
635+
self._items.insert(0, {"id": "i-new", "data": {"question": "inserted"}})
636+
return response
637+
638+
639+
def test_stream_items__unversioned_read_with_a_concurrent_insert__shifts():
640+
"""Documents the failure mode the version pin exists to prevent, so a
641+
regression in the pinning shows up as this test starting to pass."""
642+
endpoint = ShiftingItemsEndpoint(
643+
[{"id": f"i{i}", "data": {"question": f"q{i}"}} for i in range(4)]
644+
)
645+
dataset = _build_dataset(endpoint, version_hash=None)
646+
647+
ids = [
648+
item["id"]
649+
for chunk in dataset.stream_items(chunk_size=2, num_threads=1)
650+
for item in chunk
651+
]
652+
653+
# i1 is returned twice and i3 never arrives.
654+
assert len(ids) != len(set(ids)), (
655+
"expected the unversioned read to duplicate an item once the dataset shifted"
656+
)
657+
658+
659+
def test_stream_items__version_pinned_read_is_unaffected_by_a_concurrent_insert():
660+
endpoint = ShiftingItemsEndpoint(
661+
[{"id": f"i{i}", "data": {"question": f"q{i}"}} for i in range(4)]
662+
)
663+
dataset = _build_dataset(endpoint, version_hash="v-hash-abc")
664+
665+
ids = [
666+
item["id"]
667+
for chunk in dataset.stream_items(chunk_size=2, num_threads=1)
668+
for item in chunk
669+
]
670+
671+
assert ids == ["i0", "i1", "i2", "i3"]
672+
assert len(ids) == len(set(ids))
673+
674+
675+
def test_stream_items__abandoned_early__does_not_block_on_in_flight_pages():
676+
"""Closing the generator must not join the outstanding requests: the yields
677+
happen inside the pool's scope, so a `with` block would turn a plain
678+
`break` into a wait for every page still in flight."""
679+
page_delay = 0.5
680+
endpoint = FakeItemsEndpoint(_rest_items(200), delay_seconds=page_delay)
681+
dataset = _build_dataset(endpoint)
682+
683+
stream = dataset.stream_items(chunk_size=10, num_threads=4)
684+
for _ in stream:
685+
break # abandon after the first chunk, with pages still in flight
686+
687+
started = time.perf_counter()
688+
stream.close()
689+
elapsed = time.perf_counter() - started
690+
691+
assert elapsed < page_delay, (
692+
f"closing the stream blocked for {elapsed:.2f}s; it must not wait for "
693+
"in-flight pages"
694+
)

0 commit comments

Comments
 (0)