Skip to content

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

Merged
tae898 merged 3 commits into
mainfrom
perf/native-streaming-drain
Nov 5, 2026
Merged

tae898 merged 3 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; rows read from the cursor ahead of the rows delivered never exceed one reply plus a batch (main has read all rows after the first 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.

After #223 (merged): iter_json_batches takes the same untagged short-cut as to_json_list. It converts a batch with json_native only when the result's sticky _tagged flag is set, read after the batch is built so it covers every reply the batch's rows came from; a result that never restored a tagged value is yielded as parsed. Two more tests: no walk for an untagged result (a spy on json_native counts zero calls and the rows equal to_json_list), and a DATE that first appears in a later reply is still converted to epoch milliseconds at batch sizes 100 and 1000 (equal to to_json_list).

Measured figures (below) are of the streaming change; the short-cut skips a walk that returns its input unchanged for such a result.

Docs and test updated in the second commit: 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-05: rebased on main after #223, short-cut added.

🤖 Generated with Claude Code

@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.

tae898 and others added 3 commits November 5, 2026 06:44
…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>
…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>
…ged reply (as to_json_list after #223)

A batch is built after the cursor reads the replies its rows came from, so the sticky _tagged flag read at that moment covers every
row of the batch: a result that never restored a tagged value is yielded as parsed. Tests: no walk for an untagged result, and a
DATE that first appears in a later reply is still converted at two batch sizes (equal to to_json_list).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@tae898
tae898 force-pushed the perf/native-streaming-drain branch from 255a7b3 to 6cf8f55 Compare November 4, 2026 21:44
@tae898
tae898 marked this pull request as ready for review November 4, 2026 21:45
@tae898 tae898 added this to the 26.11.1 milestone Nov 5, 2026
@tae898 tae898 added native The arcadedb package (native library build) performance Speed or memory of the bindings, with a measurement labels Nov 5, 2026
@tae898
tae898 merged commit 8b71fa8 into main Nov 5, 2026
53 of 55 checks passed
@tae898
tae898 deleted the perf/native-streaming-drain branch November 5, 2026 00:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

native The arcadedb package (native library build) performance Speed or memory of the bindings, with a measurement

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