Skip to content

Batch Promise iterator row reads - #233

Merged
penberg merged 14 commits into
tursodatabase:mainfrom
xoxohorses:jy--codex-batched-row-reads
Sep 18, 2026
Merged

penberg merged 14 commits into
tursodatabase:mainfrom
xoxohorses:jy--codex-batched-row-reads

Conversation

@xoxohorses

@xoxohorses xoxohorses commented Sep 12, 2026 •

Copy link
Copy Markdown
Contributor

Description

Adds RowsIterator.nextBatch(maxRows) in Rust and updates the Promise API's existing iterate() wrapper to fetch native row batches while returning standard one-row iterator results. The defaultBatchSize connection option controls the native row batch size, and QueryOptions.batchSize overrides it for one all() or iterate() call. The default is 1, which preserves current behavior and keeps the existing method signatures unchanged.

This was motivated by a production trace of a simple ordered query that returned 9,088 rows: about 280 ms of its 350 ms p50 was attributed to SQLite processing. For that result size, the default all() path makes 9,088 asynchronous Rust-to-JavaScript round trips. Batching keeps SQLite's row-at-a-time stepping inside Rust and reduces those boundary crossings while the JavaScript iterator still returns one row at a time.

Benchmark

The public benchmark prepares the table before timing, reads the same rows through each configuration, and validates every result. These averages were measured on Node 22.13.1 for x64 Linux with an Intel Xeon Platinum 8375C CPU.

Rows all() with batch size 1 all(…, { batchSize: 250 }) Speedup
1,000 13.828 ms 2.349 ms 5.89x
10,000 138 ms 22.163 ms 6.21x
100,000 1.394 s 150 ms 9.32x
1,000,000 13.954 s 1.442 s 9.68x

Benchmark source: https://github.com/xoxohorses/libsql-js/blob/cb3e8e4fa0dc14811365033f1cc4bce84e8645d8/perf/perf-libsql-batched-rows.js

How was this change tested?

  • Automated test (unit, integration, etc.)
  • Manual test (provide reproducible testing steps below)

Built the native module, then ran the public benchmark across 1,000, 10,000, 100,000, and 1,000,000 returned rows. The benchmark validated each result set.

[written by Codex]

@xoxohorses xoxohorses closed this Sep 12, 2026
@xoxohorses
xoxohorses deleted the jy--codex-batched-row-reads branch September 12, 2026 01:28
@xoxohorses
xoxohorses restored the jy--codex-batched-row-reads branch September 12, 2026 01:44
@xoxohorses xoxohorses reopened this Sep 12, 2026
@xoxohorses xoxohorses changed the title [Promise API] Add batched row iteration Add batched row iteration Sep 12, 2026
@xoxohorses xoxohorses changed the title Add batched row iteration Add batched row iteration via allBatched(batchSize) Sep 12, 2026
@xoxohorses xoxohorses changed the title Add batched row iteration via allBatched(batchSize) Batch Promise iterator row reads Sep 14, 2026
@xoxohorses
xoxohorses marked this pull request as ready for review September 14, 2026 17:50
Comment thread integration-tests/tests/async.test.js
Comment thread index.d.ts
@xoxohorses
xoxohorses requested a review from penberg September 14, 2026 18:32
@penberg
penberg force-pushed the jy--codex-batched-row-reads branch from 3dc3a56 to ecf467c Compare September 18, 2026 09:07
@penberg

penberg commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

@claude review

penberg added a commit that referenced this pull request Sep 18, 2026
Statements and row iterators hold their own references to the libSQL
connection, and libSQL only closes the SQLite handle when the last
reference is dropped. `Database.close()` therefore left the database
file open (and any read transaction of a partially consumed iterator
active) until the garbage collector reclaimed every statement. On
Windows this makes deleting the database file after `close()` fail with
`EBUSY`, which is what breaks Windows CI on #233.

This tracks each database's statements and iterators with weak
references and releases their libSQL objects on `close()`. In-flight
operations hold their own references and finish normally; an iterator
closed mid-`next()` drops its rows once that call completes. Using a
statement or iterator after `close()` throws `TypeError: The database
connection is not open`, matching better-sqlite3. The iterator wrappers
in `compat.js` and `promise.js` now run errors through `convertError` so
that surfaces as the same `TypeError`.

A second reference was hiding in iterator results: a local libSQL `Row`
holds a clone of the statement (and so the connection), and the `Record`
returned by `RowsIterator.next()` kept that row alive until GC. Records
now copy the column values out when the row is produced (only the first
column when plucking), so they no longer pin the connection.

Unlike better-sqlite3, closing with an active iterator is still allowed
(better-sqlite3 throws "This database connection is busy executing a
query"), so the active-iterator sync test is libsql-only.

Tests: new regression tests in `sync.test.js` and `async.test.js` fail
with `SQLITE_BUSY` without the fix and pass with it.
xoxohorses and others added 14 commits September 18, 2026 14:32
Closing the iterator resets the statement, but a nextBatch() already
running kept stepping it, which restarts the query from the first row.
If the batch filled up, the statement was left active with its timeout
guard dropped, holding a read lock until the statement was reused.

Track closure with a flag that the batch loop checks before each step,
and reset the statement after the loop whenever the iterator was closed.
The statement has already been stepped by query() when nextBatch()
validates maxRows, so returning early left it active with its timeout
guard registered. A rejected next() does not trigger return() in
`for await`, so nothing else released it.
The synchronous API reads rows one at a time and its option parsing only
recognizes queryTimeout, so `statement.all(undefined, { batchSize: 250 })`
binds the options object and throws, and defaultBatchSize is ignored.
@penberg
penberg force-pushed the jy--codex-batched-row-reads branch from ecf467c to 11c8ea0 Compare September 18, 2026 13:12
@penberg
penberg merged commit 2760431 into tursodatabase:main Sep 18, 2026
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.

2 participants