Skip to content

Native iter_json_batches reads the cursor one batch at a time - #242

Draft
tae898 wants to merge 2 commits into
mainfrom
perf/native-streaming-drain
Draft

tae898 wants to merge 2 commits into
mainfrom
perf/native-streaming-drain

Conversation

@tae898

@tae898 tae898 commented Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #239

On the native build iter_json_batches drained the whole result into dicts, converted every row, and only then cut the list into batches, so batch_size bounded nothing. It now reads the cursor one batch at a time: at most one reply (_NEXT_BATCH, 2048 rows) and one output batch are held, and the first batch is returned after reading only the rows it needs.

The cut is src/arcadedb/_native/database.py: a new NativeResultSet._iter_dict_batches(size) (reads _buf and _fill() as _drain_dicts does, without collecting the result), and iter_json_batches now converts each batch with json_native as it is yielded. The batch size is still validated when the first batch is asked for, as before.

Same output: rows, order, types and batch boundaries are those of to_json_list cut at batch_size (every batch full except the last; an empty result yields nothing). One behaviour differs on purpose and follows __next__: the cursor is read when the next batch is asked for, so a result closed between two batches (by close() or by leaving its with block) raises the "closed before all its rows were read" error, where before the batches already built kept coming. An engine error met while reading surfaces after the batches before it. A generator that is dropped half-way leaves the cursor to the result set (close(), with, __del__), as dropping an iteration does.

Not changed: to_columns, to_arrow and to_polars still drain first, because a column's type is decided over all its values. The peak RSS of those paths is measured with this one in the study below, so a follow-up can be judged on a figure.

Tests (tests/test_native_streaming_batches.py, native backend only, collected through conftest.py like the other native-observing files): equal to to_json_list for ten batch sizes over a 5000-row result of several replies; the buffer never holds more than one reply plus a batch; the first batch is returned after at most one read of the cursor and leaves the cursor open; close() between two batches raises and closes the cursor; leaving a with block mid-iteration; a dropped generator; a batch size below one. The existing iter_json_batches tests (test_core.py, test_resultset.py, test_resultset_read_errors.py, test_nonfinite_floats.py) run on both builds and are unchanged.

Overlap with #223 (perf/native-json-list-untagged, to_json_list skips the walk when no reply was tagged): both touch NativeResultSet in _native/database.py, in different hunks (#223: __init__, _lazy, _fill, to_json_list, _exec; this PR: a new method after _drain_dicts and iter_json_batches). Neither hunk overlaps, so the two should merge in either order without a conflict. Merge order: #223 first is natural, and then iter_json_batches here can skip json_native for a result that never restored a tagged value, with the same one-line self._tagged check that to_json_list gets there; that follow-up is not in this PR because _tagged does not exist on main.

Draft: no merge until the peak-RSS figure is in. Peak RSS of a 1M-row read, native against the JVM wheel, comes from a probe (dropped if the native peak is not clearly above the JVM wheel's), then a timed A/B on the maintainer's laptop with peak RSS as the gating figure and a wall guard.

Docs and test updated in 255a7b3: the buffer-bound test now counts rows read from the cursor against rows delivered at every batch (main has read all 5000 rows after the first batch of 100, so it fails there), and the docs and docstrings say that batch_size bounds memory on the native build, with the measured laptop figures and 95% intervals.

Edited 2026-11-04 to add this note.

🤖 Generated with Claude Code

…239)

iter_json_batches drained the whole result into dicts (_drain_dicts), converted every row, and only then cut the list into batches, so
batch_size bounded nothing and the peak held every row twice. It now holds at most one reply (_NEXT_BATCH rows) and one output batch,
and reads the cursor when the next batch is asked for. Rows, order, types and batch boundaries are those of to_json_list cut at
batch_size. A result closed between two batches raises the closed-before-read error, as the other iterators do. to_columns, to_arrow and
to_polars are unchanged (a column type is decided over all rows).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@tae898

tae898 commented Nov 4, 2026

Copy link
Copy Markdown
Collaborator Author

Test runs for this PR, read on 2026-11-04:

Run Branch Code Result
41725183711 perf/native-streaming-drain (head 5111419832) this PR every job green (all 60 checks)
41725245718 tmp/fails-on-main-239 (head 75fb079667) main plus only the new test file test_native_streaming_batches.py: 4 failed, 14 passed; every other file passes (linux-x86_64 py3.12 parity: 2012 passed, 4 failed, 0 errors)

The four that fail on main: test_the_cursor_is_read_when_the_next_batch_is_asked_for, test_close_between_two_batches_raises_and_closes_the_cursor, test_leaving_the_with_block_mid_iteration_closes_the_cursor, test_an_abandoned_generator_leaves_the_result_to_its_owner.

One gap: the buffer-bound test passes on main too, so it does not catch the unbounded drain this PR fixes. The memory claim rests on the timed study's headline (peak RSS of a 1M-row iter_json_batches, queued); the test gets a main-failing form before this PR leaves draft. The tests-only branch is deleted after this read.

@tae898

tae898 commented Nov 4, 2026 •

Copy link
Copy Markdown
Collaborator Author

Timed A/B (native wheel; arm A = main's CI native wheel, arm B = the same tree with only this PR's files, A/A control; laptop timed lane, tools/ab.py --noise, ABBA, round 0 dropped). B/A median with 95% CI (below 1 = this PR is faster or smaller):

operation role B/A A/A
peak RSS of iter_json_batches over 1M rows (batch 10,000) gating 0.286 [0.284, 0.289] 0.999 [0.998, 1.001]
time to the first batch gating (guard 1.05) 0.010 [0.009, 0.010] 1.006 [0.994, 1.013]
wall time of the whole iteration gating (guard 1.05) 0.958 [0.952, 0.966] 1.001 [0.990, 1.007]
small to_list (control) control 0.992 [0.987, 0.997] 1.002 [0.994, 1.004]
RSS growth during the iteration info 0.178 [0.177, 0.180] 0.999 [0.997, 1.000]

Clean pairs per figure: 10; every run passed its arm check, and the output differential (iter_json_batches against to_json_list at ten batch sizes, arm A against arm B) passed before the run. By the rule written before the run this is a SHIP: peak memory is far below 1.00, the first batch and the total time are within their guards (both faster), and the control is flat. The buffer-bound unit test now has a main-failing form (255a7b3): with only the new tests applied to main, tests/test_native_streaming_batches.py fails 5 of its 18 cases on every parity leg (run 41775976858), and the full CI of this head is green.

The merge waits for the end of the current batch of timed studies that pin main (merging now would move their pins); the PR leaves draft at that point.

Edited 2026-11-04: the main-failing test line now records the CI proof.

…r-bound test counts rows read ahead so it fails on main

The old test watched the result set's buffer, which the drain on main empties as it goes, so it passed there. The new one counts rows read from the cursor against rows delivered at every batch: main has read all 5000 after the first batch of 100, the change at most one reply ahead. Docs carry the measured ratios with 95% intervals from the laptop study (copied by script from ab.txt).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Native iter_json_batches drains the whole result before it batches, so batch_size does not bound memory

1 participant