Skip to content

Commit 8705389

Browse files
alexkuzmikclaude
andcommitted
fix(dataset): address review findings on the chunked reader
- Validate the page response instead of defaulting `total` to zero. A missing, non-numeric or negative `total` capped the read at page 1 and returned part of the dataset as if it were all of it; it now raises. - Cap `chunk_size` at DATASET_STREAM_BATCH_SIZE. It was forwarded straight to the endpoint's `size`, so oversized values could materialize an unbounded page and values above int32 failed only after the request went out. Rejects rather than clamps: quietly returning smaller pages than asked for would look like the argument had no effect. - Defer the dataset-id lookup in `stream_items()` to first iteration by making the Dataset hook a generator. The laziness test only checked httpx and passed despite the eager by-name lookup; it now asserts the lookup too. - Document the `nb_samples` valid range and its ValueError. - Cover the `Dataset.from_public` id seeding, including the no-id fallback. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 85de2df commit 8705389

6 files changed

Lines changed: 250 additions & 20 deletions

File tree

apps/opik-documentation/documentation/fern/docs-v2/evaluation/advanced/manage_datasets.mdx

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -418,8 +418,7 @@ You can download a dataset from Opik using the `get_dataset` method:
418418
### Downloading large datasets faster
419419

420420
Dataset items are fetched a page at a time, and those pages are downloaded concurrently. Raise
421-
`num_threads` to speed up a large read — the default is 4, and 8 is usually worth trying on
422-
datasets of tens of thousands of items:
421+
`num_threads` to speed up a large read — the default is 4:
423422

424423
```python title="Python" language="python"
425424
from opik import Opik
@@ -451,7 +450,7 @@ for chunk in dataset.stream_items(chunk_size=5000, num_threads=8):
451450
```
452451

453452
Chunks arrive in dataset order; only the last one may be shorter than `chunk_size`. Both methods
454-
accept the same `filter_string`, and `nb_samples` to stop after a given number of items:
453+
accept the same `filter_string`, and `nb_samples` to read only the first N items:
455454

456455
```python title="Python" language="python"
457456
for chunk in dataset.stream_items(
@@ -462,11 +461,15 @@ for chunk in dataset.stream_items(
462461
process(chunk)
463462
```
464463

464+
`nb_samples` must be a positive integer — omit it or pass `None` to read everything. Passing `0`
465+
or a negative value raises `ValueError` rather than being treated as a limit.
466+
465467
<Note>
466-
`chunk_size` controls how many items each request fetches (default 2000). Fetching a chunk costs
467-
a fixed overhead whatever its size, so lowering it makes the whole read slower — raise it to
468-
trade memory for speed. Lower it only when individual items are large, since up to
469-
`2 * num_threads` chunks are held in memory at once.
468+
`chunk_size` controls how many items each request fetches. It defaults to 2000, which is also
469+
the maximum — a larger value raises `ValueError`, so that peak memory stays bounded. Fetching a
470+
chunk costs a fixed overhead whatever its size, so lowering it makes the whole read slower;
471+
lower it only when individual items are large, since up to `2 * num_threads` chunks are held in
472+
memory at once.
470473
</Note>
471474

472475
## Filtering datasets programmatically

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@
2222
DATASET_STREAM_BATCH_SIZE = 2000
2323

2424
DATASET_ITEMS_READ_NUM_THREADS = 4
25+
# Page-size ceiling for reads, deliberately the same as the batch size above: a
26+
# read should never ask the backend for a bigger page than the SDK's own read
27+
# batch, so peak memory stays bounded the way it was before pages were fetched
28+
# in parallel. Unlike the thread ceiling this one rejects rather than clamps --
29+
# silently handing back smaller pages than asked for would look like the
30+
# argument had no effect.
31+
DATASET_ITEMS_READ_MAX_CHUNK_SIZE = DATASET_STREAM_BATCH_SIZE
2532
# Ceiling on dataset read threads. The SDK's httpx client pools 100
2633
# connections, so a caller passing an arbitrarily large num_threads would
2734
# otherwise queue pages behind the pool instead of speeding anything up.

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

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,9 @@ def get_items(
127127
Retrieve dataset items as a list of dictionaries.
128128
129129
Args:
130-
nb_samples: Maximum number of items to retrieve. If not set, all items are returned.
130+
nb_samples: Maximum number of items to retrieve. Must be a positive
131+
integer; omit it or pass ``None`` to return all items. Zero and
132+
negative values raise rather than being treated as a limit.
131133
num_threads: Number of item pages fetched concurrently. Must be a
132134
positive integer, defaults to 4; pass ``1`` to fetch
133135
sequentially. Raising it speeds up large reads at the cost of
@@ -188,30 +190,34 @@ def stream_items(
188190
data plus its ``id``.
189191
190192
Args:
191-
chunk_size: Number of items per chunk, defaulting to the same
192-
batch size the typed item stream reads with
193+
chunk_size: Number of items per chunk, defaulting to and capped at
194+
the same batch size the typed item stream reads with
193195
(``constants.DATASET_STREAM_BATCH_SIZE``). Fetching a chunk
194196
costs a fixed overhead whatever its size, so lowering this
195-
makes the whole read slower; raise it to trade memory for
196-
speed, bearing in mind that up to ``2 * num_threads`` chunks
197-
are held at once.
197+
makes the whole read slower; lower it when the items are
198+
individually large, bearing in mind that up to
199+
``2 * num_threads`` chunks are held in memory at once.
198200
num_threads: Number of chunks fetched concurrently. Must be a
199201
positive integer, defaults to 4; pass ``1`` to fetch
200202
sequentially. Capped at
201203
``constants.DATASET_ITEMS_READ_MAX_THREADS``.
202204
filter_string: Optional OQL filter string to filter dataset items.
203205
Accepts the same expressions as :meth:`get_items`.
204-
nb_samples: Maximum number of items to read. If not set, the whole
205-
dataset is read.
206+
nb_samples: Maximum number of items to read. Must be a positive
207+
integer; omit it or pass ``None`` to read the whole dataset.
208+
Zero and negative values raise rather than being treated as a
209+
limit.
206210
207211
Yields:
208212
Lists of dictionaries representing the dataset items, in dataset
209213
order. The last chunk may be shorter than ``chunk_size``; empty
210214
chunks are never yielded.
211215
212216
Raises:
213-
ValueError: If ``chunk_size`` or ``num_threads`` is not a positive
214-
integer, or ``nb_samples`` is not a positive integer.
217+
ValueError: If ``num_threads`` is not a positive integer, if
218+
``chunk_size`` is not a positive integer or exceeds
219+
``constants.DATASET_ITEMS_READ_MAX_CHUNK_SIZE``, or if
220+
``nb_samples`` is not a positive integer.
215221
216222
Example:
217223
>>> for chunk in dataset.stream_items(chunk_size=2000, num_threads=8):
@@ -226,6 +232,11 @@ def stream_items(
226232
raise ValueError("chunk_size must be a positive integer")
227233
if chunk_size < 1:
228234
raise ValueError("chunk_size must be a positive integer")
235+
if chunk_size > constants.DATASET_ITEMS_READ_MAX_CHUNK_SIZE:
236+
raise ValueError(
237+
"chunk_size must not exceed "
238+
f"{constants.DATASET_ITEMS_READ_MAX_CHUNK_SIZE}, got {chunk_size}"
239+
)
229240
if isinstance(num_threads, bool) or not isinstance(num_threads, int):
230241
raise ValueError("num_threads must be a positive integer")
231242
if num_threads < 1:
@@ -1117,7 +1128,10 @@ def __internal_api__stream_item_chunks__(
11171128
nb_samples: Optional[int],
11181129
filter_string: Optional[str],
11191130
) -> Iterator[List[Dict[str, Any]]]:
1120-
return rest_operations.stream_dataset_item_chunks(
1131+
# A generator, so `self.id` -- which resolves the dataset by name over
1132+
# REST when it hasn't been seeded -- is not touched until the caller
1133+
# actually starts iterating. Keeps `stream_items()` free of I/O.
1134+
yield from rest_operations.stream_dataset_item_chunks(
11211135
rest_client=self._rest_client,
11221136
dataset_id=self.id,
11231137
chunk_size=chunk_size,

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

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,19 @@
2020
from typing import Any, Callable, Deque, Dict, Iterator, List, Optional
2121
import collections
2222

23+
import opik.exceptions as exceptions
2324
from opik.rest_api import client as rest_api_client
2425
from opik.rest_api.core import api_error as rest_api_error
2526
from opik.rest_client_configurator import retry_decorator
2627

2728
LOGGER = logging.getLogger(__name__)
2829

30+
_MALFORMED_PAGE = (
31+
"Malformed response from the dataset items endpoint. "
32+
"The page count for the rest of the read is derived from this response, so "
33+
"continuing would silently return only part of the dataset."
34+
)
35+
2936

3037
def stream_item_chunks(
3138
rest_client: rest_api_client.OpikApi,
@@ -91,9 +98,9 @@ def _read_pages(
9198
how many pages there are to fan out over.
9299
"""
93100
first_page = fetch_page(1)
101+
total = _page_total(first_page)
94102
yield _page_items(first_page)
95103

96-
total = first_page.get("total") or 0
97104
wanted = total if max_items is None else min(total, max_items)
98105
last_page = math.ceil(wanted / chunk_size)
99106
if last_page <= 1:
@@ -172,5 +179,38 @@ def fetch_page(page: int) -> Dict[str, Any]:
172179

173180

174181
def _page_items(page: Dict[str, Any]) -> List[Dict[str, Any]]:
175-
content: List[Dict[str, Any]] = page.get("content") or []
182+
content = page.get("content")
183+
if content is None:
184+
content = []
185+
if not isinstance(content, list):
186+
raise exceptions.OpikException(
187+
f"{_MALFORMED_PAGE} Expected 'content' to be a list, got "
188+
f"{type(content).__name__}."
189+
)
176190
return content
191+
192+
193+
def _page_total(page: Dict[str, Any]) -> int:
194+
"""Read the item count the rest of the read is planned against.
195+
196+
Validated rather than defaulted: the page count is derived from ``total``,
197+
so a missing or non-numeric value would silently cap the read at the first
198+
page and hand back part of the dataset as if it were all of it.
199+
"""
200+
if not isinstance(page, dict):
201+
raise exceptions.OpikException(
202+
f"{_MALFORMED_PAGE} Expected a JSON object, got {type(page).__name__}."
203+
)
204+
205+
total = page.get("total")
206+
# bool is an int subclass, and True would silently read as 1 item.
207+
if isinstance(total, bool) or not isinstance(total, int):
208+
raise exceptions.OpikException(
209+
f"{_MALFORMED_PAGE} Expected an integer 'total', got {total!r}."
210+
)
211+
if total < 0:
212+
raise exceptions.OpikException(
213+
f"{_MALFORMED_PAGE} Expected a non-negative 'total', got {total}."
214+
)
215+
216+
return total

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,3 +176,38 @@ def test_backend_returns_none_count__property_returns_none():
176176

177177
assert count is None
178178
mock_rest_client.datasets.get_dataset_by_id.assert_called_once()
179+
180+
181+
def test_from_public__response_carries_id__id_returned_without_a_lookup():
182+
"""The get-dataset response already holds the id, so reads must not pay a
183+
second by-name lookup for it."""
184+
mock_rest_client = Mock()
185+
mock_dataset_public = DatasetPublic(
186+
id="01a06bd3-f379-7231-a8ff-842808c8ba38",
187+
name="test_dataset",
188+
dataset_items_count=7,
189+
)
190+
191+
dataset = Dataset.from_public(
192+
dataset_fern=mock_dataset_public,
193+
project_name="Test project",
194+
rest_client=mock_rest_client,
195+
)
196+
197+
assert dataset.id == "01a06bd3-f379-7231-a8ff-842808c8ba38"
198+
mock_rest_client.datasets.get_dataset_by_identifier.assert_not_called()
199+
200+
201+
def test_from_public__response_without_id__falls_back_to_the_lookup():
202+
mock_rest_client = Mock()
203+
mock_rest_client.datasets.get_dataset_by_identifier.return_value.id = "looked-up"
204+
mock_dataset_public = DatasetPublic(name="test_dataset")
205+
206+
dataset = Dataset.from_public(
207+
dataset_fern=mock_dataset_public,
208+
project_name="Test project",
209+
rest_client=mock_rest_client,
210+
)
211+
212+
assert dataset.id == "looked-up"
213+
mock_rest_client.datasets.get_dataset_by_identifier.assert_called_once()

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

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import pytest
99

10+
import opik.exceptions as exceptions
1011
from opik.api_objects import constants
1112
from opik.api_objects.dataset import rest_operations
1213
from opik.api_objects.dataset.dataset import Dataset
@@ -173,14 +174,21 @@ def test_stream_items__nb_samples_above_total__reads_everything():
173174

174175

175176
def test_stream_items__lazy__no_request_until_iterated():
177+
"""No I/O at all before the first chunk is pulled -- including the
178+
by-name dataset-id lookup, which is a REST call of its own."""
176179
endpoint = FakeItemsEndpoint(_rest_items(10))
177180
dataset = _build_dataset(endpoint)
181+
lookup = dataset._rest_client.datasets.get_dataset_by_identifier
178182

179183
stream = dataset.stream_items()
180184

181185
assert endpoint.calls == []
186+
assert lookup.call_count == 0
187+
182188
next(iter(stream))
189+
183190
assert endpoint.requested_pages == [1]
191+
assert lookup.call_count == 1
184192

185193

186194
def test_stream_items__data_with_id_key__real_item_id_wins():
@@ -430,3 +438,126 @@ def test_get_items__positional_args_unchanged__nb_samples_still_first():
430438
dataset = _build_dataset(endpoint)
431439

432440
assert len(dataset.get_items(10)) == 10
441+
442+
443+
def _endpoint_returning(body: Dict[str, Any]) -> Mock:
444+
"""An items endpoint that returns one fixed, possibly malformed, page."""
445+
446+
def call(path: str, *, method: str, params: Dict[str, Any]) -> Mock:
447+
response = Mock()
448+
response.status_code = 200
449+
response.headers = {}
450+
response.text = ""
451+
response.json.return_value = body
452+
return response
453+
454+
return call
455+
456+
457+
@pytest.mark.parametrize(
458+
"total",
459+
[None, "abc", 1.5, True, -1],
460+
ids=["missing", "string", "float", "bool", "negative"],
461+
)
462+
def test_stream_items__malformed_total__raises_instead_of_truncating(total):
463+
"""A bad `total` would cap the read at page 1 and look like a short
464+
dataset, so it has to fail loudly rather than silently."""
465+
body: Dict[str, Any] = {"content": _rest_items(2)}
466+
if total is not None:
467+
body["total"] = total
468+
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+
)
474+
dataset = Dataset(
475+
name="test-dataset",
476+
description=None,
477+
project_name=None,
478+
rest_client=mock_rest_client,
479+
)
480+
481+
with pytest.raises(exceptions.OpikException, match="Malformed response"):
482+
list(dataset.stream_items())
483+
484+
485+
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+
)
491+
dataset = Dataset(
492+
name="test-dataset",
493+
description=None,
494+
project_name=None,
495+
rest_client=mock_rest_client,
496+
)
497+
498+
with pytest.raises(exceptions.OpikException, match="Malformed response"):
499+
list(dataset.stream_items())
500+
501+
502+
def test_stream_items__total_zero_with_empty_content__reads_nothing():
503+
"""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+
)
509+
dataset = Dataset(
510+
name="test-dataset",
511+
description=None,
512+
project_name=None,
513+
rest_client=mock_rest_client,
514+
)
515+
516+
assert list(dataset.stream_items()) == []
517+
518+
519+
def test_stream_items__chunk_size_at_the_cap__accepted():
520+
endpoint = FakeItemsEndpoint(_rest_items(10))
521+
dataset = _build_dataset(endpoint)
522+
523+
chunks = list(
524+
dataset.stream_items(chunk_size=constants.DATASET_ITEMS_READ_MAX_CHUNK_SIZE)
525+
)
526+
527+
assert sum(len(chunk) for chunk in chunks) == 10
528+
529+
530+
@pytest.mark.parametrize(
531+
"chunk_size",
532+
[
533+
constants.DATASET_ITEMS_READ_MAX_CHUNK_SIZE + 1,
534+
10**9,
535+
2**31,
536+
],
537+
)
538+
def test_stream_items__chunk_size_above_the_cap__raises_before_any_request(chunk_size):
539+
"""Oversized pages are rejected client-side; forwarding them would let the
540+
backend materialize an unbounded response, and values beyond int32 would
541+
only fail after the request went out."""
542+
endpoint = FakeItemsEndpoint(_rest_items(10))
543+
dataset = _build_dataset(endpoint)
544+
545+
with pytest.raises(ValueError, match="chunk_size must not exceed"):
546+
dataset.stream_items(chunk_size=chunk_size)
547+
548+
assert endpoint.calls == []
549+
550+
551+
def test_get_items__chunk_size_cap_applies_through_get_items():
552+
"""get_items() never exceeds the cap, since it uses the default."""
553+
endpoint = FakeItemsEndpoint(
554+
_rest_items(constants.DATASET_ITEMS_READ_MAX_CHUNK_SIZE + 5)
555+
)
556+
dataset = _build_dataset(endpoint)
557+
558+
dataset.get_items()
559+
560+
assert all(
561+
call["params"]["size"] <= constants.DATASET_ITEMS_READ_MAX_CHUNK_SIZE
562+
for call in endpoint.calls
563+
)

0 commit comments

Comments
 (0)