Skip to content

[NA] [SDK] perf: stream Dataset.insert so a large insert holds batches, not items - #8122

Open
alexkuzmik wants to merge 4 commits into
mainfrom
alexkuzmik/NA-stream-dataset-insert
Open

[NA] [SDK] perf: stream Dataset.insert so a large insert holds batches, not items#8122
alexkuzmik wants to merge 4 commits into
mainfrom
alexkuzmik/NA-stream-dataset-insert

Conversation

@alexkuzmik

Copy link
Copy Markdown
Collaborator

Details

Dataset.insert read everything before sending anything: it built the dataclasses, the survivors of deduplication, the REST models and the list of batches — four structures as long as the input — then handed every batch to the thread pool at once, so the whole dataset stayed resident until the last upload finished, however small the batches were.

It now consumes its argument lazily and uploads as it reads, so insert accepts any iterable — a generator over a file or a database cursor included — and a dataset larger than memory no longer has to be materialised as a list first. Two changes make that hold end to end: sequence_splitter.stream_into_batches yields batches as items arrive (with split_into_batches now list() around it, so the eager callers keep their list and the size accounting has one implementation), and _send_batches waits before pulling instead of submitting everything, so at most num_threads batches exist at once and the producer only runs as far ahead as the uploads drain.

Deduplication still works, lazily, but is not free: its content hashes grow with the number of items inserted and outlive the call, so deduplication=False is what actually bounds memory. That is now stated in the docstring and the docs instead of implied.

One behaviour changes deliberately: a source that fails halfway used to mean nothing was sent, and now the failure reaches the caller with the earlier batches already persisted, the batch still accumulating lost with it. That matches the contract num_threads already documents for a failed batch, and a test pins it.

Change checklist

  • User facing
  • Documentation update

Issues

  • Resolves #

AI-WATERMARK

AI-WATERMARK: yes

  • Tools: Claude Code
  • Model(s): Claude Opus 5
  • Scope: full implementation
  • Human verification: code review

Testing

  • pytest tests/unit — 5158 passed, 3 skipped
  • make precommit — ruff, ruff-format, mypy on the changed files

New tests cover the splitter and the insert path. For the splitter: streaming yields exactly what the eager version returns across the existing scenarios (count limit, size limit, both, empty, oversized item), a generator input needs no len(), only the first batch plus the item that closed it is read to produce one batch, and an oversized item still comes out ahead of the batch accumulating around it.

For insert: a generator uploads the same items a list does and is consumed exactly once; dataclass items work from a generator; deduplication still drops duplicates lazily; the source is read no further ahead than the uploads (measured as the gap between items read and items whose upload has started, parametrized over 1 and 4 threads — barrier-free, so it cannot flake); no more batches are in flight than num_threads; and a source that raises midway leaves the earlier batches persisted.

Documentation

manage_datasets.mdx — a section on inserting more items than fit in memory: the generator form, that the iterable is consumed once, what happens if reading fails partway, and that deduplication=False is what bounds memory.

…hes, not items

`insert` read everything before sending anything. A call built the dataclasses,
the survivors of deduplication, the REST models and the list of batches - four
structures as long as the input - and then handed every batch to the thread pool
at once, so the whole dataset stayed resident until the last upload finished,
however small the individual batches were.

It now consumes its argument lazily and uploads as it reads. `insert` takes any
iterable, including a generator over a file or a database cursor, so a dataset
larger than memory no longer has to be materialised as a list first. Two changes
make that hold end to end:

- `sequence_splitter.stream_into_batches` yields batches as items arrive.
  `split_into_batches` is now `list()` around it, so the eager callers keep
  their list and the size accounting has one implementation. `max_length=None`
  means "no count limit" rather than `len(items)`, which no iterable can answer
  and which meant the same thing.
- `_send_batches` waits before pulling instead of submitting everything, so at
  most `num_threads` batches exist at any moment and the producer only runs as
  far ahead as the uploads drain.

Deduplication still works, lazily, but is not free: the content hashes it keeps
grow with the number of items inserted and outlive the call, so
`deduplication=False` is what actually bounds memory. Said plainly in the
docstring and the docs rather than implied.

One behaviour changes deliberately. Reading and converting used to finish before
the first upload, so a source that failed halfway meant nothing was sent; the
failure now reaches the caller with the batches before it already persisted, and
the batch still accumulating is lost with it. That matches the contract
`num_threads` already documents for a batch that fails to upload, and it is
pinned by a test rather than left to be discovered.

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/L labels Sep 2, 2026
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

⏱️ pre-commit per-hook timing

Hook Description Result Duration
🐍 mypy — python sdk Static type check 1.38s
🐍 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) 1.46s
⏭️ 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 2, 2026

Copy link
Copy Markdown
Contributor

🌿 Preview your docs: https://opik-preview-01a06799-4a73-729f-8674-277ae8caeef5.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://app.portkey.ai/ (timeout)
↳ on page: /docs/opik/integrations/portkey
https://code.claude.com/docs/en/monitoring-usage (timeout)
↳ on page: /docs/opik/integrations/claude-agent-sdk
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/ (timeout)
↳ on page: /docs/opik/integrations/mistral
https://console.x.ai/ (403)
↳ on page: /docs/opik/integrations/xai-grok
https://docs.konghq.com/gateway/latest/ (timeout)
↳ on page: /docs/opik/integrations/kong-ai-gateway
https://docs.konghq.com/gateway/latest/ai-gateway/ (timeout)
↳ on page: /docs/opik/integrations/kong-ai-gateway
https://docs.predibase.com/integrations/comet (403)
↳ on page: /docs/opik/integrations/predibase
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://openrouter.ai/anthropic (timeout)
↳ on page: /docs/opik/integrations/openrouter
https://openrouter.ai/docs (timeout)
↳ on page: /docs/opik/integrations/openrouter
https://openrouter.ai/docs/features/structured-outputs (timeout)
↳ on page: /docs/opik/integrations/openrouter
https://openrouter.ai/google (timeout)
↳ on page: /docs/opik/integrations/openrouter
https://openrouter.ai/models (timeout)
↳ on page: /docs/opik/integrations/openrouter
https://openrouter.ai/openai (timeout)
↳ on page: /docs/opik/integrations/openrouter
https://opik-preview-01a06799-4a73-729f-8674-277ae8caeef5.docs.buildwithfern.com/docs/opik/administration/workspace-settings/feedback_definitions (500)
↳ on page: /docs/opik
https://opik-preview-01a06799-4a73-729f-8674-277ae8caeef5.docs.buildwithfern.com/docs/opik/integrations/deepseek (500)
↳ on page: /docs/opik/integrations/overview
https://opik-preview-01a06799-4a73-729f-8674-277ae8caeef5.docs.buildwithfern.com/docs/opik/integrations/openai-codex (500)
↳ on page: /docs/opik/integrations/overview
https://opik-preview-01a06799-4a73-729f-8674-277ae8caeef5.docs.buildwithfern.com/docs/opik/integrations/openclaw (500)
↳ on page: /docs/opik/integrations/overview
https://portal.azure.com/ (403)
↳ on page: /docs/opik/administration/workspace-settings/ai_providers
https://portkey.ai/ (timeout)
↳ on page: /docs/opik/integrations/portkey
https://www.helicone.ai/ (timeout)
↳ on page: /docs/opik/integrations/helicone
https://www.ibm.com/products/watsonx-ai (timeout)
↳ on page: /docs/opik/integrations/watsonx
https://www.together.ai/ (timeout)
↳ on page: /docs/opik/integrations/together-ai
https://x.ai/ (403)
↳ on page: /docs/opik/integrations/xai-grok


📌 Results for commit f048ef4

def read_items(path):
with open(path) as file:
for line in file:
yield {"input": json.loads(line)}

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.

Example fails without json import

The copyable Python example calls json.loads(line) without importing json, so it raises NameError on the first input line — should we add import json to the snippet?

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
apps/opik-documentation/documentation/fern/docs-v2/evaluation/advanced/manage_datasets.mdx
around lines 306-312, update the `read_items` Python example to import the `json` module
before calling `json.loads(line)`. Keep the snippet self-contained and directly runnable
as shown.

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 c18d743 addressed this comment by adding import json to the Python example before json.loads(line) is called.

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.

Fixed in c18d743 — added import json to the snippet.

🤖 Reply posted via /address-github-pr-comments

Comment on lines 705 to +706
with futures.ThreadPoolExecutor(max_workers=num_threads) as pool:
submitted = [
pool.submit(
self._insert_batch_with_retry,
batch,
batch_group_id=batch_group_id,
in_flight: Set[futures.Future] = set()

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.

Concurrent inserts can exhaust threads

Each parallel Dataset.insert creates its own ThreadPoolExecutor, so overlapping calls can allocate up to num_threads workers each and grow the process thread count without a shared bound — should we use a dataset/client-owned bounded executor with explicit shutdown and tracked futures?

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 705-706, refactor
`_send_batches` so parallel inserts do not create a new `ThreadPoolExecutor` for every
call. Use a shared, bounded executor owned by the `Dataset` or its client, retain and
manage submitted futures so exceptions still propagate correctly, and add an explicit
lifecycle/shutdown path that safely waits for outstanding uploads.

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.

Skipping — pre-existing: the per-insert ThreadPoolExecutor came with num_threads and this PR does not change how it is created, only how much work is queued into it (strictly less — at most num_threads batches now, rather than every batch up front). Concurrent inserts each allocating a pool is worth addressing, but it is the same before and after this change, so it belongs in its own ticket rather than being conflated with the memory bound.

🤖 Reply posted via /address-github-pr-comments

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 clarification—I’ll keep the distinction between pre-existing executor lifecycle concerns and PR-introduced queueing changes in mind, and save it to memory once the PR is merged.

Comment on lines 762 to +764
self._hashes.add(item_hash)
self._id_to_hash[item.id] = item_hash

return deduplicated_items
yield item

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.

_deduplicate writes item hashes to self._hashes/self._id_to_hash before _insert_batch_with_retry succeeds, so failed batches remain cached and a later insert with deduplication=True skips them — should we stage these entries and commit them only after persistence succeeds?

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 762-764 (and
`__internal_api__insert_items_as_dataclasses__` around lines 807-810), fix
`_deduplicate` so hashes are not treated as persisted before the corresponding item has
actually been uploaded. Refactor the insert pipeline to stage deduplication hashes per
batch and commit them to `self._hashes`/`self._id_to_hash` (and set `_hashes_synced`
accordingly) only after `_insert_batch_with_retry` succeeds; any exception while
streaming, batching, converting, or sending should invalidate/roll back the staged
entries for that batch rather than leaving them cached, so a later deduplicated insert
can retry stranded items. Preserve duplicate filtering within the same in-progress
insert without leaving unsaved items in the long-lived cache, and add a regression test
covering a source/upload failure followed by a retry.

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.

Skipping — pre-existing and unchanged by this PR: the hashes were recorded before any upload here too, since deduplication ran to completion before the first batch was sent. Streaming interleaves the two but does not alter which side of the upload the hash lands on.

It is a real product issue, though — a failed batch leaves its items looking inserted to the next deduplicated insert. Out of scope for a memory change; worth its own ticket, and _deduplicate's docstring now says the cache is not transactional.

🤖 Reply posted via /address-github-pr-comments

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 clarifying—this is pre-existing behavior and out of scope for this PR. I’ll save that context to memory once the PR is merged.

Comment on lines +807 to +810
# Generator, not a list: conversion happens one item ahead of the batch
# being filled, so the REST models for the whole insert are never all
# alive at once.
rest_items = (self._convert_to_rest_item(item) for item in items_to_send)

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.

Partial failures leave count cache stale

_send_batches() can upload earlier batches before the lazy producer raises, so count invalidation is skipped and dataset_items_count/TestSuite.items_count returns the stale pre-insert value — should we clear the cached count in a finally or equivalent failure path whenever a batch may have been uploaded?

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 807-810, update
__internal_api__insert_items_as_dataclasses__ so the lazy streaming upload invalidates
_dataset_items_count even when producing a later batch raises after earlier batches were
persisted. Wrap _send_batches and the count invalidation in a finally-based cleanup (or
equivalent failure path), preserving the original exception while ensuring subsequent
dataset_items_count accesses refresh from the backend.

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 c18d743 addressed this comment by invalidating _dataset_items_count in a finally block around _send_batches, including partial-upload failures.

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.

Fixed in c18d743 — the invalidation now sits in a finally, so a source that fails part-way or a batch that fails after earlier ones landed no longer leaves items_count reporting the number from before an insert that did add items. Test: test_source_raises_mid_stream__cached_item_count_is_invalidated.

🤖 Reply posted via /address-github-pr-comments

Comment thread sdks/python/src/opik/api_objects/dataset/dataset.py
alexkuzmik and others added 2 commits September 3, 2026 13:23
- `next(batch_iterator, None)` did not typecheck: the sentinel is not a
  batch. `StopIteration` says the same thing without one, and CI caught it
  because my own verification never ran the hooks (the file list reached
  pre-commit as a single argument, so every hook reported nothing to check).
- Invalidate the cached item count in a `finally`. A partial insert still
  changed the dataset, so a failure part-way used to skip the invalidation
  and leave `items_count` reporting the number from before it.
- Import `json` in the docs example, which called `json.loads` without it.
- Cover the generator path against a real backend, sequentially and fanned
  out, since the existing e2e insert tests all pass a materialised list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Referenced in a PR reply, so it needs to be true in the code: an item's hash
is recorded before its batch is known to have landed, which predates streaming
but was written down nowhere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Python SDK E2E Tests Results (Python 3.11)

298 tests  +2   291 ✅ +3   4m 11s ⏱️ -20s
  1 suites ±0     7 💤  - 1 
  1 files   ±0     0 ❌ ±0 

Results for commit b9da87a. ± Comparison against base commit c9eb439.

♻️ This comment has been updated with latest results.

@alexkuzmik
alexkuzmik marked this pull request as ready for review September 3, 2026 14:04
@alexkuzmik
alexkuzmik requested review from a team as code owners September 3, 2026 14:04
Every other test here shrinks `DATASET_ITEMS_MAX_BATCH_SIZE` to 2 so batching
is observable, which means none of them would notice the streaming splitter
mishandling either cap at its actual value.

Two cases, both fed by a one-shot generator:

- 2,500 items on the default worker count, filling two whole batches and
  leaving a partial one. Asserts the sizes are exactly `[1000, 1000, 500]`,
  that every item arrives once with a unique id - nothing lost at a boundary,
  nothing sent twice by the pool - and that one batch_group_id covers the
  whole stream.
- 120 items of 100KB, so a batch fills on bytes long before it fills on
  count. Asserts no batch exceeds the 5MB the uploads are built around and
  that the count cap is not what split them; without the size accounting all
  120 would arrive as a single batch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Python SDK Unit Tests Results (Python 3.14)

5 163 tests  +19   5 161 ✅ +19   1m 57s ⏱️ -25s
    1 suites ± 0       2 💤 ± 0 
    1 files   ± 0       0 ❌ ± 0 

Results for commit b6ee2a1. ± Comparison against base commit c9eb439.

This pull request removes 1 and adds 20 tests. Note that renamed tests count towards both.
tests.unit.test_id_helpers ‑ test_is_valid_uuid_v7__non_v7_uuid__returns_false[917aa5a0-24dd-4b93-a1a8-b9749f755dc8]
tests.unit.api_objects.dataset.test_dataset_client.TestStreamingInsert ‑ test__source_is_read_no_further_ahead_than_the_uploads[1]
tests.unit.api_objects.dataset.test_dataset_client.TestStreamingInsert ‑ test__source_is_read_no_further_ahead_than_the_uploads[4]
tests.unit.api_objects.dataset.test_dataset_client.TestStreamingInsert ‑ test_dataclass_items__accepted_from_a_generator
tests.unit.api_objects.dataset.test_dataset_client.TestStreamingInsert ‑ test_deduplication__still_drops_duplicates_from_a_generator
tests.unit.api_objects.dataset.test_dataset_client.TestStreamingInsert ‑ test_generator_input__consumed_exactly_once
tests.unit.api_objects.dataset.test_dataset_client.TestStreamingInsert ‑ test_generator_input__every_item_uploaded_once
tests.unit.api_objects.dataset.test_dataset_client.TestStreamingInsert ‑ test_large_items_from_a_generator__split_by_payload_size
tests.unit.api_objects.dataset.test_dataset_client.TestStreamingInsert ‑ test_many_items_from_a_generator__batched_at_the_real_count_cap
tests.unit.api_objects.dataset.test_dataset_client.TestStreamingInsert ‑ test_parallel__never_more_batches_in_flight_than_workers
tests.unit.api_objects.dataset.test_dataset_client.TestStreamingInsert ‑ test_source_raises_mid_stream__cached_item_count_is_invalidated
…

♻️ This comment has been updated with latest results.

Comment on lines +859 to +869
def counting_source():
nonlocal pulled
for item in _make_items(item_count):
pulled += 1
yield item

def tracked_upload(*args, **kwargs):
nonlocal started, peak_gap
with lock:
started += 1
peak_gap = max(peak_gap, pulled - started * batch_size)

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.

Unsynchronized lookahead bound measurement

counting_source increments pulled outside lock while tracked_upload reads it under the lock, so peak_gap measures an unsynchronized producer/worker interleaving rather than a deterministic bound — should we protect both accesses with lock or use a queue/event-based measurement?

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
859-869, fix the `counting_source` and `tracked_upload` concurrency measurement so
`pulled` is not incremented outside the lock while being read under it. Protect both the
increment and read with the same lock, or replace the counter-based approach with a
queue/event-based measurement that deterministically tracks producer progress relative
to uploads. Keep the `peak_gap` assertion focused on the intended bounded-prefetch
behavior.

Comment on lines +164 to +166
assert list(sequence_splitter.stream_into_batches(items, **limits)) == (
sequence_splitter.split_into_batches(items, **limits)
)

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.

💡 suggestion | Testing

This compatibility test cannot fail. split_into_batches is now defined as list(stream_into_batches(...)), so both sides of the assertion call the same generator and the test reduces to list(x) == list(x) — it holds for any implementation, including a broken one.

That matters more than the usual tautology nit, because this is the test positioned to catch a boundary change. I differential-tested the refactor against main over 300 randomized inputs and the batch boundaries are byte-identical, so the code is fine — but that confidence came from outside the suite, not from here.

Pinning the expected batches literally makes it a real regression test and keeps the documented boundary rules (oversized item emitted alone and ahead of the accumulating batch; inclusive size bound) readable in one place:

Suggested change
assert list(sequence_splitter.stream_into_batches(items, **limits)) == (
sequence_splitter.split_into_batches(items, **limits)
)
@pytest.mark.parametrize(
"items, limits, expected",
[
(
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
{"max_length": 4},
[[1, 2, 3, 4], [5, 6, 7, 8], [9, 10]],
),
([1, 2, 3], {"max_length": 10}, [[1, 2, 3]]),
([], {"max_length": 4}, []),
],
)
def test_stream__yields_the_documented_batches(self, items, limits, expected):
assert list(sequence_splitter.stream_into_batches(items, **limits)) == expected

The two megabyte-object cases are worth keeping as their own case with expected batch lengths asserted (comparing the objects themselves is noisy), so the size-limit and oversized-item paths stay covered.

🤖 Review posted via /review-github-pr

Comment on lines +719 to +724

try:
batch = next(batch_iterator)
except StopIteration:
break

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.

question | Performance

Worth a follow-up ticket rather than anything for this PR — the memory fix here is real and I verified the in-flight bound holds.

Waiting before pulling is what caps in-flight batches at num_threads, but it also means next(batch_iterator) runs on the submitting thread: the source read, DatasetItem(**item), content_hash(), _convert_to_rest_item, and the splitter's JSON size walk all execute between submissions. So the loop alternates wait → produce → submit, and workers that free up during the produce step stay idle until it finishes.

Simulating this loop's semantics (100 batches, 1.0s upload):

threads producer cost/batch worker utilization
8 0s 96%
8 0.1s 28%
4 0.1s 63%

The diagnostic detail is that more threads make it worse — a serialized producer can't feed them — and makespan has a floor at n × produce_cost regardless of pool size, so raising num_threads can't recover it. Under the GIL the producer's CPU work also competes with the upload threads' serialization.

The standard fix decouples the two concerns that are currently fused, and gets bounded memory and work-conserving uploads instead of trading one for the other:

q = queue.Queue(maxsize=num_threads * 2)   # capacity = backpressure
# producer thread: for batch in batches: q.put(batch)
# N workers:       while (b := q.get()) is not SENTINEL: upload(b)

put() blocks when full, so peak memory stays bounded by maxsize while a freed worker picks up work immediately. In the same simulation, raising capacity from 8 to 16 cut makespan from 14.6 to 8.2 — capacity and parallelism want to be separate knobs.

🤖 Review posted via /review-github-pr

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.

Correction to the comment above — I compared against the wrong baseline, and this PR is a throughput improvement, not a regression.

The ~28% figure measures this submit loop in isolation, against an implicit 100% baseline. That baseline is wrong. On main, all the producer work — building the four full-length lists — ran to completion before _send_batches was called, so during that entire phase no uploads were in flight and every worker sat idle. Interleaving production with uploads beats front-loading it.

Same parameters as above (100 batches, 1.0s upload, 0.1s produce per batch, 8 threads), measured across the whole insert() call rather than just the loop:

producer phase upload phase total utilization
main 10.0s (workers idle) 12.5s 22.5s 56%
this PR interleaved 14.6s 86%

Across the range:

threads produce/batch main this PR delta
4 0.05s 30.0s 26.4s −12%
4 0.1s 35.0s 27.8s −21%
4 0.3s 55.0s 33.4s −39%
8 0.1s 22.5s 14.6s −35%
8 0.3s 42.5s 17.8s −58%
8 0s (free producer) 12.5s 13.0s +4%
1 any 0%

The only case this PR loses is a zero-cost producer, which doesn't occur — conversion and the splitter's size walk both cost something.

So the corrected picture for this change: CPU complexity unchanged (O(n), same constant — verified by interleaved in-process benchmark, flat ns/item across a 16× size range), peak memory improved O(n) → O(num_threads), and wall-clock improved 12–58% in realistic cases.

The bounded-queue suggestion still stands on its own merits — 86% isn't 100%, and decoupling queue depth from worker count closes the remainder — but it's an incremental improvement on something this PR already made substantially better, not a fix for anything introduced here. Apologies for the misframing.

🤖 Review posted via /review-github-pr

@JetoPistola

Copy link
Copy Markdown
Contributor

👋 Review summary

What looks good

  • The four-lists-to-four-generators change is the right decomposition, and the commentary explains why each stage had to become lazy rather than just noting that it did. The _send_batches rewrite is correct: futures.wait rebinding in_flight to the not-done set, prompt future.result() on each completion so a failure surfaces without draining the rest, and the in-flight bound genuinely holds.
  • Hoisting __internal_api__sync_hashes__ out of the generator into the caller is a subtle catch — inside a generator body it would have fired mid-upload on the first pull.
  • Moving the count-cache invalidation into finally is the right call, and the reasoning (a partial insert still changed the dataset) is the part that's easy to miss.
  • Documenting that deduplication=True retains a hash per item and outliving the call, rather than implying it, is the most valuable line in the diff. It tells users the flag is a memory decision, not just a speed one.
  • Deliberately pinning the changed partial-failure semantics with a test, instead of leaving it as an undocumented consequence, is good discipline.

Overall
The approach is sound and the scope is right. I differential-tested the splitter refactor against main over 300 randomized inputs (mixed strings/dicts/lists, count-only, size-only, and both limits) and the batch boundaries are byte-identical — the refactor is behaviour-preserving, and it deletes a branch that was already unreachable on main, since max_payload_size_MB gets reassigned to float("inf") before the loop.

One structural note, filed inline as a follow-up rather than a change request: this fixes the memory half of the problem but leaves the throughput half, and both come from the same design decision — producer and consumers sharing a thread. Because _send_batches waits before pulling, all the per-item CPU work runs on the submitting thread between submissions, so worker utilization drops to ~28% at 8 threads once producing a batch costs anything (and more threads make it worse). A bounded queue.Queue with the producer on its own thread would get bounded memory and work-conserving uploads together instead of trading one against the other.

Separately, and pre-existing rather than introduced here: count-only callers (delete_traces, the four annotation_queue methods) pay a full jsonable_encoder.encode() plus recursive size walk per item for a limit that's never consulted — roughly 7× slower than necessary on flat ID lists. Worth its own small ticket now that the dead guard is gone.

Inline comments: 1 suggestion, 1 question — nothing blocking.

This review does not constitute an approval; a human reviewer should still approve.

🤖 Review posted via /review-github-pr

@JetoPistola

Copy link
Copy Markdown
Contributor

Follow-up: 14 of the 16 split_into_batches call sites don't need the list

Not review feedback on this PR — keeping split_into_batches as list(stream_into_batches(...)) was the right call here, since making it lazy would have meant touching 16 call sites in a PR that's about insert. This is a note for the follow-up ticket.

I audited every call site to see how many actually need a materialized list. It's 2 of 16.

Genuinely need a list:

Site Why
api_objects/experiment/experiment.py:245 len(batches) twice — a log line, and worker_count = min(num_threads, len(batches), MAX_THREADS). Sizing the pool needs the count up front.
cli/imports/experiment.py:665 enumerate(item_batches, start=1) plus len(item_batches) inside the loop for a batch N/M progress message.

Single forward pass — the list is pure overhead (14):

  • message_processing/batching/batchers.py:28, :65for batch in ...: batches.append(Message(batch=batch))
  • api_objects/opik_client.py:478, :849, :900, :961 — inline for batch in split_into_batches(...):
  • api_objects/annotation_queue/annotation_queue.py:230, :259, :348, :376for batch in batches:
  • api_objects/threads/threads_client.py:154 — inline for-loop
  • api_objects/experiment/experiment.py:146for batch in batches:
  • cli/migrate/datasets/version_replay.py:392for batch in item_batches:
  • api_objects/dataset/dataset.py:908 (delete) — for batch in batches:

None of these index, re-iterate, or take a length. Each batch is consumed and discarded before the next is pulled — exactly the shape stream_into_batches was written for.

Several run on inputs that get large: delete_traces, the four annotation-queue ID paths, and dataset.delete. Pointing those at stream_into_batches gives them the same O(n) → O(num_threads) peak-memory win insert just got, for free, with no behaviour change since a single forward pass is identical either way.

Two options for the ticket, and the first seems lower-risk:

  1. Repoint the 14 at stream_into_batches. Mechanical, no behaviour change, leaves split_into_batches for the 2 that need a list.
  2. Make split_into_batches lazy and fix up the 2. Cleaner end state, but it changes the return type of a widely-used helper, and experiment.py:245 wants the count for pool sizing anyway — so you'd reintroduce a list() there regardless.

Also worth folding into the same ticket: count-only callers still pay a full jsonable_encoder.encode() plus recursive size walk per item for a limit that's never consulted (~7× slower than necessary on flat ID lists). Pre-existing — on main the 0.0 if max_payload_size_MB is None guard was already dead code, since the parameter gets reassigned to float("inf") before the loop — but now that the dead branch is gone, restoring the short-circuit behind a real flag is straightforward. delete_traces and the annotation-queue paths are the ones paying for it.

🤖 Review posted via /review-github-pr

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.

2 participants