Skip to content

[OPIK-8253] [SDK] [DOCS] feat: parallel chunked dataset item reads - #8156

Merged
alexkuzmik merged 6 commits into
mainfrom
aliaksandrk/OPIK-8253-optimized-dataset-items-read
Sep 4, 2026
Merged

[OPIK-8253] [SDK] [DOCS] feat: parallel chunked dataset item reads#8156
alexkuzmik merged 6 commits into
mainfrom
aliaksandrk/OPIK-8253-optimized-dataset-items-read

Conversation

@alexkuzmik

@alexkuzmik alexkuzmik commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Details

get_items() walked the cursor-chained /items/stream endpoint and rebuilt every row into a typed DatasetItem before returning, so a large read was one serial request chain plus per-item pydantic validation. This adds Dataset.stream_items() — a chunked reader that fetches pages of the id-addressed GET /v1/private/datasets/{id}/items endpoint concurrently through the SDK's own httpx client, bypassing the Fern-generated typed layer — and rewires get_items() on top of it.

  • stream_items(chunk_size, num_threads, filter_string, nb_samples) yields lists of item dicts in dataset order. The read is pinned to a single dataset version, so it is a snapshot: items inserted or deleted while it runs do not affect it. Page 1 is fetched first for its total; the rest fan out over a thread pool behind an ordered queue with a bounded look-ahead of 2 * num_threads chunks, so a slow consumer cannot make the reader buffer the whole dataset.
  • get_items() keeps its payload exactly — it is now a flatten of stream_items(). Both share a single projection, so data keys that shadow DatasetItem fields are still dropped and still warned about once per read. It also gains optional num_threads and chunk_size (appended to the signature, so existing positional callers are unaffected), since it would otherwise be stuck on the defaults.
  • DatasetVersion gets the same method, pinned to its version hash.
  • The backend-fetch factories (Dataset.from_public, rest_operations.get_datasets) now seed Dataset.id from the response they already hold, so moving to the id-addressed endpoint costs no extra round trip. This also removes that lookup from delete(), get_version_info() and dataset_items_count.
  • The paginated endpoint is used rather than the streaming one because its pages are independently addressable, which is what makes concurrent fetching possible at all. Its ordering (ORDER BY (workspace_id, dataset_id, id) DESC) is equivalent to the stream's (id DESC) within a workspace and dataset, verified empirically below.

Behavior change. get_items(nb_samples=0) (or a negative value) previously returned a single item: the stream parser's len(result) == nb_samples check never fires, and the caller loop then stops after the first yield. It now raises ValueError. Everything else about the payload is unchanged.

Follow-up worth a separate ticket (no action needed here). DatasetItemDAO.getItems runs Mono.zip(countMono, columnsMono) on every page, so each page request costs three ClickHouse queries — the items query plus a full COUNT over the filtered dataset and a column-types scan — where the stream endpoint runs one. A 100k read at the default chunk size therefore issues roughly 150 queries instead of 50, and all but the first COUNT is unused (the reader only needs total from page 1). Measured per-request cost is 22% higher than the stream endpoint at identical page size, which is why the sequential case does not improve. A total=false / skip-count query parameter on that endpoint would make this path strictly faster than the cursor stream at every thread count.

Review round 2 (2 findings from @petrotiurin, 3 from baz-reviewer[bot]):

  • Reads were not snapshot-consistent — 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, returning one item twice and skipping another. The cursor stream got snapshot semantics for free from its id < last_retrieved_id seek, so this was a genuine regression against it, and it landed on callers that match items by exact equality. Every page now carries one version hash, resolved once per read; backends without dataset versioning fall back to a live read, documented on stream_items(). Regression tests drive an insert at offset 0 between pages and assert the unversioned read duplicates (so a regression in the pinning surfaces as that test passing) while the pinned read does not.
  • Abandoning the iterator blocked — the yields happen inside the thread pool's with scope, so closing the generator ran shutdown(wait=True) and waited for every outstanding page, potentially at GC time on an unrelated line. Now try/finally with cancel_futures=True, wait=False; test asserts close() returns in under one page delay with pages in flight.
  • Two test-quality findings were acknowledged and deliberately not taken here: driving the real HttpClient via httpx.MockTransport (worth doing SDK-wide rather than in this PR — the plumbing is covered e2e today), and tightening the cap test's assertions (correct, but the same count and content are asserted by neighbouring tests).

Because version pinning changes which backend query runs, the payload parity check was re-run against the previous implementation: byte-identical, and still identical across 3-50 pages at 1 and 8 threads.

Review round 1 (5 findings from baz-reviewer[bot], all addressed):

  • A missing, non-numeric or negative total in a page response silently capped the read at page 1 and returned part of the dataset as if it were all of it. total and content are now validated, raising OpikException that says why continuing would be wrong.
  • chunk_size was unbounded and 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. Now capped at DATASET_STREAM_BATCH_SIZE. This one rejects rather than clamps, unlike the thread ceiling — handing back smaller pages than asked for would look like the argument had no effect.
  • stream_items() resolved the dataset id eagerly, doing a by-name REST lookup before iteration began. The Dataset hook is now a generator, so it defers to first iteration; the laziness test previously only checked httpx and would have passed regardless, and now asserts the lookup itself.
  • The nb_samples ValueError was undocumented. Both docstrings and the guide now state the valid range and what to pass for an unbounded read.
  • The Dataset.from_public id seeding had no test. Added, plus the fallback case where the response carries no id.

Change checklist

  • User facing
  • Documentation update

Issues

  • Resolves OPIK-8253

AI-WATERMARK

AI-WATERMARK: yes

  • Tools: Claude Code
  • Model(s): Claude Opus 5
  • Scope: full implementation, tests, benchmarking, and documentation
  • Human verification: author review; chunk_size default and API shape chosen by the author against the measurements below

Testing

Automated

# unit — full SDK suite
cd sdks/python && venv/bin/python -m pytest tests/unit -q          # 5208 passed, 4 skipped

# e2e — against a local backend (./opik.sh)
OPIK_URL_OVERRIDE=http://localhost:5173/api/ venv/bin/python -m pytest \
  tests/e2e/test_dataset.py tests/e2e/compatibility_v1/test_dataset.py \
  tests/e2e/test_cli_import_export.py tests/e2e/evaluation -q      # 107 passed

# lint / types
pre-commit run --files <changed files>                             # ruff, ruff-format, mypy clean

New unit coverage (61 tests) drives a fake items endpoint that records every call and tracks peak in-flight requests: chunk ordering, real concurrency at num_threads > 1 versus strictly serial at 1, nb_samples limiting both items and pages fetched, filter serialization, laziness, error mapping, and argument validation. Separate tests pin get_items() to the new path, assert it equals stream_items() output, and cover its num_threads argument — that it is forwarded (real concurrency observed), that 1 is strictly serial, that the thread count never changes the result, that invalid values raise, and that positional calls like get_items(10) still mean nb_samples.

New e2e coverage: 10k items over 10 pages at 8 threads, asserting every item exactly once and that the 8-thread id order matches the 1-thread order; plus nb_samples, filter_string, and a DatasetVersion snapshot read. test_cli_import_export.py is included deliberately — the CLI import path matches dataset items by exact dict equality minus id, so any drift in the returned payload fails it.

Payload parity with the previous implementation

__internal_api__stream_items_as_dataclasses__ is unchanged on this branch, so the old get_items() expression could be run in the same process as the new one and compared directly. Ordered (not set) comparison, all byte-identical:

  • Values: nested dicts/lists, int, int > 2^53, float, both bools, None, empty dict/list/string, zero, unicode + emoji, list-of-dicts.
  • An item written through REST with all seven shadowing keys (id, source, description, trace_id, span_id, evaluators, execution_policy) inside its data — both paths reduce it identically and both warn once.
  • filter_string matching some rows and none; nb_samples at 1/2/3 and at the exact item count.
  • Ordering across 2, 5, 10, 20, 50 and 200 pages at 1 and 8 threads — identical every time.

Performance

100k items, local Docker backend, small rows (~480 bytes). Baseline is main checked out in a separate worktree, 3+ repeats each, fresh client and get_dataset() per repeat.

median vs baseline
baseline get_items() 3.82 s 1.0x
get_items() (default: 2000 x 4 threads) 1.35 s 2.8x

stream_items() by thread count, chunk_size=2000:

num_threads median vs baseline
1 4.01 s 0.9x
2 2.06 s 1.9x
4 (default) 1.17 s 3.3x
8 0.77 s 5.0x
16 0.98 s 3.9x
32 1.12 s 3.4x

Read honestly: the gain here is parallelism, not the Fern bypass. Decomposing a sequential read shows the bypass does work as intended — client-side parsing drops from 0.64 s to 0.21 s, a 3x saving — but it is outweighed by the paginated endpoint's 22% higher per-request server cost described under Details, which is why num_threads=1 lands slightly behind the baseline. Larger pages amortize it (measured at 5000: 2.43 s sequential, 1.8x faster than baseline, and 5.5x at 4 threads), but chunk_size is deliberately capped at DATASET_STREAM_BATCH_SIZE so a read can never request a bigger page than the typed stream's own batch and peak memory stays bounded as before. chunk_size is therefore a downward knob, for datasets whose individual items are large; the sequential case is left to the backend follow-up above rather than bought with unbounded pages. Two caveats on the absolute numbers: a single local backend container understates network latency (which parallelism helps more with) and overstates backend contention (which it helps less with), and these rows are small, so this measures request overhead rather than parse throughput — heavier per-item payloads should favor the Fern bypass more.

Not run

Backend, frontend and TypeScript SDK suites — no changes in those areas.

Documentation

  • apps/opik-documentation/documentation/fern/docs-v2/evaluation/advanced/manage_datasets.mdx — new "Downloading large datasets faster" section leading with get_items(num_threads=8), then stream_items() for datasets too large to hold in memory, a filter_string/nb_samples example, and a note on the chunk_size tradeoff.
  • Docstrings on get_items() and stream_items() cover num_threads, the returned shape, the 2 * num_threads look-ahead, and which direction to move chunk_size.

alexkuzmik and others added 3 commits September 4, 2026 12:42
…em reads

get_items() downloads every item and rebuilds each one as a pydantic model
before returning, which dominates the wait on large datasets. stream_items()
reads the paginated items endpoint through the SDK's own httpx client, fetches
pages across a thread pool with a bounded look-ahead, and yields raw dicts in
dataset order.

Measured against a local backend on 20k items: 3.3x faster at 8 threads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
get_items() now flattens stream_items() instead of walking the cursor-chained
typed stream, so it inherits the parallel paginated read. Its public signature
and payload are unchanged: both read paths share one projection, which drops
data keys shadowing DatasetItem fields and warns once per read, exactly as
building a DatasetItem did.

Verified byte-identical to the previous implementation (ordered comparison)
across nested containers, every JSON scalar, empties, unicode, all seven
shadowing keys, filter_string, nb_samples, and 2-50 pages at 1 and 8 threads.
The one behavior change: nb_samples=0 or negative now raises ValueError rather
than returning a single item.

The backend-fetch factories seed Dataset.id from the response they already
hold, so switching to the id-addressed endpoint costs no extra round trip.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
stream_items() defaulted to 1000-item chunks, which doubled the request count
against the paginated endpoint relative to the typed stream and made a
sequential read materially slower than before. Reuse
constants.DATASET_STREAM_BATCH_SIZE so both read paths issue the same number
of requests, and point the tests and the e2e helper at that constant so they
cannot drift from the default.

Measured on 100k items, num_threads=1: 4.01s at 2000 against 4.43s for the
previous implementation. The default 4 threads gives 1.35s (2.8x).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added documentation Improvements or additions to documentation python Pull requests that update Python code tests Including test files, or tests related like configuration. Python SDK 🔴 size/XL labels Sep 4, 2026
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

⏱️ pre-commit per-hook timing

Hook Description Result Duration
🐍 mypy — python sdk Static type check 0.81s
🐍 fix end of files — python sdk Ensure files end in a newline 0.03s
🐍 trim trailing whitespace — python sdk Strip trailing whitespace 0.03s
🐍 ruff-format — python sdk Format Python code (ruff) 0.01s
🐍 ruff — python sdk Lint + autofix Python (ruff) 0.01s
Total (5 ran) 0.89s
⏭️ 39 skipped (no matching files changed)
Hook Description Result
🤖 trim trailing whitespace — optimizer Strip trailing whitespace ⏭️
🤖 fix end of files — optimizer Ensure files end in a newline ⏭️
🤖 check yaml — optimizer Validate YAML syntax ⏭️
🤖 check json — optimizer Validate JSON syntax ⏭️
🤖 check toml — optimizer Validate TOML syntax ⏭️
🤖 check for added large files — optimizer Block large files (>1MB) ⏭️
🔐 detect private key — optimizer Block committed private keys ⏭️
🤖 check for merge conflicts — optimizer Block merge-conflict markers ⏭️
🤖 check for case conflicts — optimizer Block case-only name clashes ⏭️
🤖 pyupgrade — optimizer Modernize Python syntax ⏭️
🤖 ruff — optimizer Lint + autofix Python (ruff) ⏭️
🤖 ruff-format — optimizer Format Python code (ruff) ⏭️
🤖 mypy — optimizer Static type check ⏭️
📓 nbstripout — optimizer notebooks Strip notebook output ⏭️
📝 markdownlint — optimizer Lint Markdown ⏭️
🔤 codespell — optimizer Fix common misspellings ⏭️
📊 radon cc — optimizer Cyclomatic-complexity gate ⏭️
📊 radon raw — optimizer Raw size metrics gate ⏭️
📊 xenon — optimizer Fail on complexity thresholds ⏭️
📊 lizard — optimizer Cyclomatic-complexity gate ⏭️
🧹 vulture — optimizer Find dead code ⏭️
🛡️ trim trailing whitespace — guardrails Strip trailing whitespace ⏭️
🛡️ fix end of files — guardrails Ensure files end in a newline ⏭️
🛡️ ruff — guardrails Lint + autofix Python (ruff) ⏭️
🛡️ ruff-format — guardrails Format Python code (ruff) ⏭️
🛡️ mypy — guardrails Static type check ⏭️
⚓ helm-docs Regenerate Helm chart README ⏭️
block non-public FE plugins Block non-public FE plugins ⏭️
☕ spotless — java backend Format Java code ⏭️
🧪 pre-commit wrapper smoke tests Self-test the wrapper scripts ⏭️
🧪 rebaseline script tests Self-test the changelog re-baseline script ⏭️
🌐 eslint — frontend Lint + autofix JS/TS ⏭️
🌐 typecheck — frontend Whole-project tsc type check ⏭️
📘 eslint — typescript sdk Lint + autofix JS/TS ⏭️
📘 typecheck — typescript sdk Whole-project tsc type check ⏭️
⚙️ actionlint — github workflows Lint GitHub Actions workflows ⏭️
🐳 hadolint — dockerfiles Lint Dockerfiles ⏭️
🌈 zizmor — github workflows security Security-scan GitHub Actions workflows ⏭️
🛡️ semgrep — java backend sql Block SQL injection-prone string formatting ⏭️

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

🌿 Preview your docs: https://opik-preview-01a06c74-11db-70dc-a2c4-e1da57063f66.docs.buildwithfern.com/docs/opik

No broken links found

Unverified links (timeout / rate-limited / server error — not failing the check)

https://aistudio.google.com/apikey (401)
↳ on page: /docs/opik/development/optimization-runs/optimization/configure_models
https://beeai.dev/ (503)
↳ on page: /docs/opik/integrations/beeai
https://chat.deepseek.com/sign_up (403)
↳ on page: /docs/opik/integrations/deepseek
https://console.cloud.google.com/iam-admin/iam (401)
↳ on page: /docs/opik/administration/workspace-settings/ai_providers
https://console.cloud.google.com/iam-admin/roles (401)
↳ on page: /docs/opik/administration/workspace-settings/ai_providers
https://console.cloud.google.com/iam-admin/serviceaccounts (401)
↳ on page: /docs/opik/administration/workspace-settings/ai_providers
https://console.mistral.ai/api-keys/ (429)
↳ on page: /docs/opik/integrations/mistral
https://console.x.ai/ (403)
↳ on page: /docs/opik/integrations/xai-grok
https://docs.predibase.com/integrations/comet (403)
↳ on page: /docs/opik/integrations/predibase
https://en.wikipedia.org/wiki/ROUGE_(metric) (429)
↳ on page: /docs/opik/evaluation/metrics/heuristic_metrics
https://learn.microsoft.com/en-us/semantic-kernel/concepts/enterprise-readiness/observability/telemetry-with-console?tabs=Powershell-CreateFile%2CEnvironmentFile&pivots=programming-language-python (timeout)
↳ on page: /docs/opik/integrations/semantic-kernel
https://learn.microsoft.com/en-us/semantic-kernel/overview/ (timeout)
↳ on page: /docs/opik/integrations/semantic-kernel
https://portal.azure.com/ (403)
↳ on page: /docs/opik/administration/workspace-settings/ai_providers
https://x.ai/ (403)
↳ on page: /docs/opik/integrations/xai-grok


📌 Results for commit 0e0bb10

@alexkuzmik
alexkuzmik marked this pull request as ready for review September 4, 2026 10:49
@alexkuzmik
alexkuzmik requested review from a team as code owners September 4, 2026 10:49
get_items() read at a fixed 4 threads, so reaching the faster end of the
range meant flattening stream_items() by hand. Accept num_threads and forward
it. Appended to the signature, so existing positional callers are unaffected;
validation is inherited from stream_items().

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread sdks/python/src/opik/api_objects/dataset/parallel_items_reader.py Outdated
Comment thread sdks/python/src/opik/api_objects/dataset/dataset.py Outdated
Comment thread sdks/python/src/opik/api_objects/dataset/dataset.py
Comment thread sdks/python/src/opik/api_objects/dataset/dataset.py Outdated
- 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>
Comment thread sdks/python/src/opik/api_objects/dataset/dataset.py
Comment thread sdks/python/tests/unit/api_objects/dataset/test_stream_item_chunks.py Outdated

@petrotiurin petrotiurin left a comment

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.

Two findings on the new reader: a read-consistency regression versus the cursor stream, and a blocking early-exit. Details inline.

Comment thread sdks/python/src/opik/api_objects/dataset/parallel_items_reader.py
Comment thread sdks/python/src/opik/api_objects/dataset/parallel_items_reader.py Outdated
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>
@CometActions

Copy link
Copy Markdown
Collaborator

Already covered by a test in this PR.

Python-SDK-only change (plus docs): get_items() now reads through the paginated items endpoint in parallel instead of the cursor stream, pins the read to a dataset version, and a public stream_items() is added. You shipped the tests for it. tests/unit/api_objects/dataset/test_stream_item_chunks.py covers page fan-out and single-thread order, nb_samples trimming, filter serialization, chunk_size/num_threads validation before any request, the malformed-'total' guard, and the version-pinned vs live-state read including the concurrent-insert shift; tests/e2e/test_dataset.py reads 10k items over 8 threads and asserts every item exactly once in the same order as num_threads=1, that get_items() equals stream_items() flattened, and that a version view reads its own snapshot. Those fail if the read drops, duplicates or reorders items, so we're not proposing anything on top. Our Playwright estate reads dataset items over REST/the TS client and never exercises the Python read path, so there was no e2e gap here to begin with.

also touches Python SDK

Run

Advisory, from the QA test radar. Nothing here blocks this PR, and anything it proposes is a draft for review.

Comment on lines +679 to +690
page_delay = 0.5
endpoint = FakeItemsEndpoint(_rest_items(200), delay_seconds=page_delay)
dataset = _build_dataset(endpoint)

stream = dataset.stream_items(chunk_size=10, num_threads=4)
for _ in stream:
break # abandon after the first chunk, with pages still in flight

started = time.perf_counter()
stream.close()
elapsed = time.perf_counter() - started

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.

Wall-clock assertion flakes in CI

The test uses a real 0.5-second delay and requires stream.close() to finish within that wall-clock window, so scheduler or CI contention can make correct shutdown(wait=False) behavior flaky — should we use event-based synchronization to hold in-flight workers, then release and join them during teardown?

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_stream_item_chunks.py` around lines
679-692, refactor
`test_stream_items__abandoned_early__does_not_block_on_in_flight_pages` to remove the
real 0.5-second delay and wall-clock assertion. Use synchronization events to hold
in-flight requests, assert that `stream.close()` returns before those events are
released, then release the workers and join or otherwise clean them up during teardown.

Comment on lines +1174 to +1175
version_info = self.get_version_info()
version_hash = version_info.version_hash if version_info else None

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.

Nit: get_version_info() isn't cached, so every read pays an extra round trip to resolve the pin. evaluate() already calls it separately (evaluator.py:335), so an evaluation run resolves it twice, and the PR seeds Dataset.id elsewhere specifically to avoid a round trip like this one. Memoizing it on the instance would keep the pin free for the many small get_items() calls (cli/imports/utils.py, evaluation/rest_operations.py).

Not blocking, and not free either: a cache needs an invalidation story for a version committed after the dataset object was created, which is why resolving per read is a defensible default.

@alexkuzmik
alexkuzmik merged commit 0a31ec1 into main Sep 4, 2026
135 of 139 checks passed
@alexkuzmik
alexkuzmik deleted the aliaksandrk/OPIK-8253-optimized-dataset-items-read branch September 4, 2026 13:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation Python SDK python Pull requests that update Python code 🔴 size/XL tests Including test files, or tests related like configuration.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants