Skip to content

Register frames, so a statement can match on columns a program already holds - #16

Merged
tamnd merged 1 commit into
mainfrom
register
Aug 19, 2026
Merged

Register frames, so a statement can match on columns a program already holds#16
tamnd merged 1 commit into
mainfrom
register

Conversation

@tamnd

@tamnd tamnd commented Aug 19, 2026

Copy link
Copy Markdown
Owner

Columns a program is already holding become something a statement can match on, under a name the program picks. An Arrow table goes in, which is what apache-arrow and everything built on it hands out, and so does an object of column name to typed array for a caller with none of that installed.

await conn.register("people", table);
const rows = await conn.query(`MATCH (p:people) WHERE p.age > 40 RETURN p.name AS name`);
await conn.unregister("people");

Nothing is copied. What the engine is told is where each column is, how wide its values are and what they mean, and a statement that names the frame builds vectors pointing straight at the caller's arrays. So registering costs what describing the columns costs and not what the rows cost. On this machine, with npm run bench:register:

registering 1000000 rows, fastest of 5
the round trip alone         0.016 ms
typed arrays, 10 rows        0.034 ms     3429 ns/row (over 10)
typed arrays                 0.026 ms        0 ns/row
arrow table                  0.033 ms        0 ns/row
arrow, two batches           1.102 ms        1 ns/row
arrow strings                1.191 ms        1 ns/row
plain array                126.722 ms      127 ns/row

reading 1000000 rows, fastest of 5
sum an integer column        1.326 ms frame      1.559 ms table
find a row by string         2.182 ms frame      5.810 ms table

The first block is the claim: a million rows and ten rows cost the same, and both of them are mostly the promise. The second is the reason to have the call at all, which is that reading a frame is as fast as reading a table of the database and faster where the database has to decode.

apache-arrow is a dev dependency and not a dependency, and it is one so that the tests can build the tables this path reads. A table is recognized by its shape rather than by its class, so any library that speaks that shape takes the same path and a caller who never registers a frame installs nothing.

What copies, and why

Three things, all of them said rather than hidden. A string column is walked once, to check every offset at registration so that reading it afterwards cannot fail. A table that arrived as several record batches is concatenated, because a column of a frame is one run of bytes and two batches are two of them. A column given as a plain array is read into a buffer of this client's own, because an array holds JavaScript values rather than numbers and there is nothing in it to point at. That last one is the expensive way in, and it is there so that a caller with an array is not stuck rather than because it is the way to do this.

The sliced column

The awkward case is a chunk of a sliced column, and arrow-js splits the difference in a way worth writing down. Slicing narrows the values buffer and the offsets buffer to the rows that were kept, so those two already start at row zero of the chunk. It leaves the validity bitmap and a boolean column's bits alone, because both count in bits and a bit is not a place a typed array can start. So the chunk's offset means those two buffers and nothing else. Reading it as a row offset into whole buffers, which is the obvious reading, makes a string column fail loudly and a fixed width column answer the wrong bytes quietly. The test that registers table.slice(1, 3) and reads the numbers back as well as the words is what caught it.

Shape of the calls

All three are asynchronous, including registered(), because all three take the connection's lock and a call that waits on the event loop is the thing this client does not do. That is why registered() is a method and not a getter.

register answers the count of rows. Registering the same name again replaces what it stands for, columns and all. Registering over a table the database already holds is refused, since a statement naming it would mean the stored one. A frame with no rows is a table to match on and answers nothing. A null anywhere is refused by column and row, and registering inside a transaction is refused because a frame is registered on the session, which is the thing the transaction is running on.

The frame belongs to the connection it was registered on and goes when that connection does. Nothing is written to the file, and nothing writes to it either: a statement that inserts into or deletes from a registered name is refused with the reason, because that memory is the caller's array.

What is here

src/frame.rs reads a frame and describes it, src/register.rs is the three tasks, src/buffer.rs gains the typing of a plain array by its first value with widening, and src/conn.rs gains the three methods. 37 tests in test/register.test.mjs, covering every column kind, every Arrow width, both multi-chunk and sliced input, a bitmap sliced partway through a byte, a mutation through a registered typed array showing up in the next statement, and every refusal by its message. A README section, the four types in types/header.d.ts, both type fixtures, the benchmark and the regenerated API report.

cargo fmt --check, cargo clippy --all-features, npm test (205 tests, 0 fail), npm run check:types and npm run check:package are all green locally.

…y holds

An Arrow table or an object of typed arrays goes in under a name, and a
statement that names it reads the caller's arrays where they lie. The
engine is told where each column is, how wide its values are and what
they mean, so registering costs what describing the columns costs and
not what the rows cost: a million rows in 26 microseconds against ten
rows in 34, both of them mostly the promise.

apache-arrow is a dev dependency and not a dependency. A table is
recognized by its shape rather than by its class, so any library that
speaks that shape takes the same path and a caller who never registers
a frame installs nothing.

Three things copy and all three are said rather than hidden. A string
column is walked once, to check every offset at registration so that
reading it afterwards cannot fail. A table that arrived as several
record batches is concatenated, because a column of a frame is one run
of bytes and two batches are two of them. A column given as a plain
array is read into a buffer of this client's own, because an array
holds JavaScript values rather than numbers and there is nothing in it
to point at.

The awkward case is a sliced column, and arrow-js splits the difference
in a way worth writing down. Slicing narrows the values buffer and the
offsets buffer, so those two already start at row zero of the chunk. It
leaves the validity bitmap and a boolean column's bits whole, because
both count in bits and a bit is not a place a typed array can start. So
the chunk's offset means those two buffers and nothing else, and the
test that reads a slice back is what caught it.

All three calls are asynchronous, including registered(), because all
three take the connection's lock and a call that waits on the event
loop is the thing this client does not do.
@tamnd
tamnd merged commit 27bf9c3 into main Aug 19, 2026
21 of 23 checks passed
@tamnd
tamnd deleted the register branch August 19, 2026 08:28
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.

1 participant