Skip to content

feat: add Statement#stepIntoStrings() to read rows in batches - #52

Closed
arv wants to merge 3 commits into
mainfrom
arv/step-into-strings
Closed

arv wants to merge 3 commits into
mainfrom
arv/step-into-strings

Conversation

@arv

@arv arv commented Oct 17, 2026

Copy link
Copy Markdown
Contributor

Adds Statement#stepIntoStrings(buffer, capacity, ...bindParameters) and Statement#closeCursor(). zero-cache's TableSource will use them to read range-fetch rows in batches instead of one iterate() step per row.

What it does

Each call steps up to capacity rows.

  • Numbers go into a caller-provided ArrayBuffer or SharedArrayBuffer: an 8-byte slot per value (int64 / double / array index) and a tag byte per value.
  • TEXT and BLOB values are created natively and returned as one array per call.
  • Layout: fixed size, 16 + 9 * capacity * columnCount bytes, documented in docs/api.md with an example decoder. Because every row fits, there is no overflow or partial-row case.
  • Integers are always int64. pluck()/expand()/raw()/safeIntegers() don't apply; the caller converts.

When a call doesn't exhaust the result (header done is 0), the statement stays locked and counts as an open iterator, exactly like iterate(). stepIntoStrings(buffer, capacity) with no bind parameters continues it, and closeCursor() abandons it.

Behavior that mirrors iterate()

  • Writes: these throw "busy" while a cursor is open, unless in unsafe mode.
  • db.close(): also throws "busy" while a cursor is open, so a cursor can't outlive its database handle. closeCursor() after close() is a no-op.
  • Abandoned cursor: one that is neither finished nor closed stays open, as an abandoned iterator does.
  • Errors close the cursor and leave the statement reusable:
    • a SQLite error mid-batch, or in the call that opens the cursor;
    • an exception thrown by a user-defined function;
    • re-entrant use from a user-defined function.
  • Argument errors don't open a cursor:
    • a bad buffer (wrong type, too small, detached);
    • a bad capacity;
    • bad bind parameters.
  • Verbose mode logs once per cursor.

Why

Measured on the rowbench comment table from zero-cache profiling: a 92-row, 6-column range fetch (5 TEXT columns, one of them 200 bytes), with a 256 MB page cache and the median of 7 interleaved runs.

ns/row × SQL floor
iterate(), row objects (current zero-cache path) 598 3.65
stepIntoStrings K=128, plain row objects built in JS 253 1.54
floor: count(length(body)) in SQL, one call 164 1.00

Strings are created natively on purpose. In the prototype, decoding UTF-8 from the buffer in JS (Buffer#latin1Slice or TextDecoder) cost 45–97 ns per string and gave back most of the gain. Creating them in the addon costs about 12 ns per string.

Testing

test/27.statement.step-into-strings.js (19 tests) covers:

  • every value type against raw().all();
  • capacities from 1 to above the row count, including exactly the row count;
  • SharedArrayBuffer;
  • locking and write blocking;
  • abandoned cursors;
  • closeCursor();
  • positional, named and pre-bound parameters;
  • every error path listed above;
  • verbose logging;
  • the closed database.

The full suite passes locally on Node 22.23, 24.18 and 26.12 (366 tests).

Not included: a version bump or release.

stepIntoStrings(buffer, capacity, ...params) steps up to `capacity` rows per
native call. Numbers are written into a caller-provided ArrayBuffer or
SharedArrayBuffer (an 8-byte slot and a tag byte per value); TEXT and BLOB
values are created natively and returned as one array per call. A call that
does not exhaust the result leaves a cursor open, which later calls continue
and closeCursor() abandons. An open cursor locks the statement and counts as an
open iterator, exactly like iterate().

On a 92-row, 6-column range fetch this is 253 ns/row with plain row objects
built in JS, against 598 ns/row for iterate() and 164 ns/row for the same scan
done entirely in SQL.
Copilot AI lite review requested due to automatic review settings October 17, 2026 02:53
@github-actions
github-actions Bot requested a review from grgbkr October 17, 2026 02:53
@arv
arv marked this pull request as draft October 17, 2026 02:55

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Statement destruction can leave cursor accounting and database locking permanently active.

1 open issue
What changed in this PR

Adds native batched row extraction via Statement#stepIntoStrings() and cursor cleanup via closeCursor().

Changes:

  • Adds batch encoding for numeric, TEXT, BLOB, and NULL values.
  • Adds cursor locking, parameter binding, typings, documentation, and tests.
  • A critical cursor-accounting cleanup issue remains during statement destruction.
File Description
test/​27.statement.step-into-strings.js Tests batching, locking, errors, and bindings.
src/​util/​binder.cpp Supports offset parameter binding.
src/​objects/​statement.hpp Declares cursor state and methods.
src/​objects/​statement.cpp Registers the new APIs.
src/​objects/​statement-batch.cpp Implements batched stepping and cursor management.
src/​better_sqlite3.cpp Includes the batch implementation.
lib/​index.d.ts Adds TypeScript declarations.
docs/​api.md Documents the APIs and buffer format.

Review effort: Lite

Comment on lines +149 to +157
void Statement::CloseCursor() {
if (!cursor_open) return;
cursor_open = false;
locked = false;
db->GetState()->iterators -= 1;
if (alive) {
sqlite3_reset(handle);
if (!bound) sqlite3_clear_bindings(handle);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changed, on purpose: this matches iterate(). An abandoned StatementIterator also never releases stmt->locked or db_state->iterators; statement-iterator.hpp says "we actually DON'T want to revert stmt->locked or db_state->iterators in this destructor, to ensure deterministic database access", so whether writes or close() succeed never depends on when GC runs. Callers must finish the cursor or call closeCursor(), as docs/api.md says, and a test covers the abandoned-cursor behavior. Leaving the thread open in case a maintainer prefers to diverge from iterate() here.

🤖 Addressed by Claude Code

A user-defined function runs JavaScript during sqlite3_step() and could detach
the buffer (transfer()) or shrink a resizable one while rows are being written.
Hold the BackingStore for the whole call and reject resizable ArrayBuffers.
Also reserve the values vector by rows and columns rather than capacity * 4.
@arv
arv marked this pull request as ready for review October 19, 2026 08:20
@arv

arv commented Oct 19, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #57: StatementIterator#nextBatch() + #restart() measures the same in zero-cache (within ±2%) with about a third of the native code and no new buffer API.

@arv arv closed this Oct 19, 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