Skip to content

A result goes to Arrow without a copy - #9

Merged
tamnd merged 2 commits into
mainfrom
arrow-over-ffm
Aug 20, 2026
Merged

A result goes to Arrow without a copy#9
tamnd merged 2 commits into
mainfrom
arrow-over-ffm

Conversation

@tamnd

@tamnd tamnd commented Aug 20, 2026

Copy link
Copy Markdown
Owner

The C ABI has a zu_result_arrow that hands a whole result to an Arrow consumer over the C Data Interface, and the JVM client had no way to call it. This is that way: zudb-arrow, one class and three static methods, giving back the ArrowReader every Arrow consumer on the JVM already takes.

try (BufferAllocator allocator = new RootAllocator();
     ArrowReader reader = Arrow.query(allocator, conn, "MATCH (p:Person) RETURN p.id AS id")) {
    while (reader.loadNextBatch()) {
        BigIntVector ids = (BigIntVector) reader.getVectorSchemaRoot().getVector("id");
        ...
    }
}

It is an artifact of its own because arrow-java is the largest dependency anything in this repository would have and the one most likely to clash with a version an application already pins. The rest of the client has no dependencies at all, and a program that reads rows or columns should keep it that way.

Nothing on the path is proportional to the answer. The arrays that cross are the buffers the executor filled, at the addresses it filled them at, so an export is a schema, a stream and the pointers in it, and batches are slices of arrays that are already in memory.

The same hundred thousand rows, statement included on every line because an export cannot be run twice against one result:

How Per row
the statement on its own 3.2 ns
r.longs(0) and a sum over the buffer 3.7 ns
Arrow.query(...) and a sum over every batch 5.1 ns
for (Row row : r) row.getLong(0) 79 ns

Read those against the first line rather than against zero. Summing through Arrow costs about 2 ns a row over the statement, against 0.5 for the borrowed column and 76 for a row at a time.

That is also why an export spends its result: once the buffers have left there is nothing on this side to read again. Result.exportArrow clears the handle before the call rather than after it, because the engine nulls the result on every path it takes, refusals included, and a result this side still thought it owned would be one a later close would free twice. The two arguments it checks itself are checked before the engine sees them, so a call refused for a stream that is nowhere spent nothing and the result is still there to read.

A node column names its table out of the catalog the connection holds, so a Result now knows which connection produced it and lends the handle back for this one call. A connection that has already closed is not a failure, and the export then names a table after its id.

Twelve tests over a real engine, covering a stored column crossing as the buffers it already was, an ORDER BY crossing through the row-built fallback, nulls, UTF-8, the batch size a consumer asked for, an empty result arriving as one empty batch, the spending, and the zoned time column Arrow has no type for. The allocator is closed after every one, so a leaked stream or reader fails the test that leaked it.

Local: 200 tests green across the four modules, javadoc clean under the release profile, and the benchmark jar builds and runs.

tamnd added 2 commits August 20, 2026 12:31
The C ABI has a zu_result_arrow that hands a whole result to an Arrow
consumer over the C Data Interface, and the JVM client had no way to
call it. This is that way: zudb-arrow, one class and three static
methods, giving back the ArrowReader every Arrow consumer on the JVM
already takes.

It is an artifact of its own because arrow-java is the largest
dependency anything in this repository would have and the one most
likely to clash with a version an application already pins. The rest of
the client has no dependencies at all, and a program that reads rows or
columns should keep it that way.

Nothing on the path is proportional to the answer. The arrays that
cross are the buffers the executor filled, at the addresses it filled
them at, so an export is a schema, a stream and the pointers in it, and
batches are slices of arrays that are already in memory. Summing a
hundred thousand rows through the reader costs about 2 ns a row over
the statement itself, against 0.5 for the borrowed column and 76 for a
row at a time.

That is also why an export spends its result: once the buffers have
left there is nothing on this side to read again. Result.exportArrow
clears the handle before the call rather than after it, because the
engine nulls the result on every path it takes, refusals included, and
a result this side still thought it owned would be one a later close
would free twice. The two arguments it checks itself are checked before
the engine sees them, so a call refused for a stream that is nowhere
spent nothing and the result is still there to read.

A node column names its table out of the catalog the connection holds,
so a Result now knows which connection produced it and lends the handle
back for this one call. A connection that has already closed is not a
failure, and the export then names a table after its id.

Twelve tests over a real engine, covering a stored column crossing as
the buffers it already was, an ORDER BY crossing through the row-built
fallback, nulls, UTF-8, the batch size a consumer asked for, an empty
result arriving as one empty batch, the spending, and the zoned time
column Arrow has no type for. The allocator is closed after every one,
so a leaked stream or reader fails the test that leaked it.
zu_result_arrow is what the engine added the revision for, so a client
that calls it is a client written against 0.12 and should say so. CI
reads the macro out of the engine's own header and compares, which is
the step that has been red on main since the engine bumped.
@tamnd
tamnd merged commit 175758e into main Aug 20, 2026
13 checks passed
@tamnd
tamnd deleted the arrow-over-ffm branch August 20, 2026 05:41
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