Skip to content

Commit 175758e

Browse files
authored
A result goes to Arrow without a copy (#9)
* A result goes to Arrow without a copy 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. * The ABI this client speaks is 0.12 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.
1 parent 34cc6b9 commit 175758e

18 files changed

Lines changed: 1030 additions & 14 deletions

File tree

.github/workflows/ci.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,14 @@ jobs:
3737
# gets exactly this artifact and the JNI provider beside it.
3838
- run: mvn $MAVEN_ARGS -pl zudb -am test
3939

40+
# The Arrow reader is a 17 artifact as well, and the README says so
41+
# in a table. This is what keeps that true. Its tests need the FFM
42+
# provider to run against, which this JDK cannot build, so what is
43+
# checked here is that the sources a 17 caller compiles against
44+
# compile on 17.
45+
- run: mvn $MAVEN_ARGS -pl zudb -am install -DskipTests
46+
- run: mvn $MAVEN_ARGS -pl zudb-arrow compile
47+
4048
# The whole client against the engine at its own HEAD, which is what
4149
# makes a red job here mean the binding is wrong about the ABI rather
4250
# than that a checked-in copy of something is stale.

README.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,47 @@ What it is worth, summing one integer column of a hundred thousand rows on an M-
6666

6767
A row at a time is a boundary crossing a cell, and a hundred crossings cost about what one borrowed buffer costs. Both surfaces are there because both are the right answer to a different question, but a loop over a million rows should be reading a column.
6868

69+
## Handing the whole result to Arrow
70+
71+
A borrowed column is the answer when your program is the one doing the arithmetic. When it is not, when the answer is going into a dataframe or a Parquet file or across a Flight connection, the thing to hand over is Arrow, and there is a module for that:
72+
73+
```xml
74+
<dependency>
75+
<groupId>dev.zudb</groupId>
76+
<artifactId>zudb-arrow</artifactId>
77+
<version>${zu.version}</version>
78+
</dependency>
79+
```
80+
81+
```java
82+
try (BufferAllocator allocator = new RootAllocator();
83+
ArrowReader reader = Arrow.query(allocator, conn, "MATCH (p:Person) RETURN p.id AS id")) {
84+
while (reader.loadNextBatch()) {
85+
BigIntVector ids = (BigIntVector) reader.getVectorSchemaRoot().getVector("id");
86+
...
87+
}
88+
}
89+
```
90+
91+
It is a separate artifact because arrow-java is the largest dependency anything here would have and the one most likely to clash with a version an application already pins. A program that reads rows or columns carries none of it. The rest of the client has no dependencies at all and this is the one line that changes that, so it is a line you write rather than one you inherit.
92+
93+
Nothing on the way out is a copy. The export goes over the Arrow C Data Interface, and the arrays that cross are the buffers the executor already filled, at the addresses it filled them at, so what an export costs is a schema, a stream, and the pointers in it. A million rows and ten thousand cost about the same. Batches are slices of those same arrays, so `Arrow.reader(allocator, result, 1000)` is about what a consumer likes to work in rather than about what gets allocated.
94+
95+
That is also why an export spends its result. Once the buffers have left there is nothing on this side to read a second time, so the `Result` is closed by the call, whatever the call answered, and every buffer a columnar reader borrowed from it before now belongs to the Arrow consumer. Closing it again is the no-op it always was, so try-with-resources around it is still the right shape to write. The reader owns what it was handed and releases it on close, which releases the result: close the reader.
96+
97+
A result the engine had to build across its rows, which is anything with an `ORDER BY`, has no buffers to hand over and is read into buffers of its own on the way out. That is the fallback working rather than the fast path failing, and the only way to tell from the outside is to time it.
98+
99+
The same hundred thousand rows, statement included this time because an export cannot be run twice against one result:
100+
101+
| How | Per row |
102+
|---|---|
103+
| the statement on its own | 3.2 ns |
104+
| `r.longs(0)` and a sum over the buffer | 3.7 ns |
105+
| `Arrow.query(...)` and a sum over every batch | 5.1 ns |
106+
| `for (Row row : r) row.getLong(0)` | 79 ns |
107+
108+
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, and the gap between the first two is arrow-java building vectors over memory it did not allocate rather than anything crossing the boundary twice.
109+
69110
## Getting rows in
70111

71112
Two ways, and which one you want follows from whether the database exists yet. There is a third below for the rows that should not go in at all.
@@ -229,6 +270,7 @@ An SDK that requires a recent JDK in 2026 excludes a large part of the enterpris
229270
| `dev.zudb:zudb` | Java 17 | the API, no native code, no FFM types in the public surface |
230271
| `dev.zudb:zudb-ffm` | Java 25 | the FFM provider, selected automatically |
231272
| `dev.zudb:zudb-jni` | Java 17 | the fallback provider |
273+
| `dev.zudb:zudb-arrow` | Java 17 | the Arrow reader, the only artifact that names arrow-java |
232274
| `dev.zudb:zudb-native` | | the `libzu` binaries, all platforms or one by classifier |
233275

234276
A `ServiceLoader` picks the provider at run time and application code never names one. The FFM artifact targets Java 25 rather than the Java 22 that finalised the API, because 22 has been out of support since September 2024 and shipping against an unsupported release only moves the problem. CI runs 17, 21, 25, and 26.

pom.xml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
<modules>
5151
<module>zudb</module>
5252
<module>zudb-ffm</module>
53+
<module>zudb-arrow</module>
5354
<module>zudb-bench</module>
5455
</modules>
5556

@@ -77,6 +78,12 @@
7778
restating the flags the suite cannot run without. -->
7879
<zu.test.args></zu.test.args>
7980

81+
<!-- arrow-java, named in one module and nowhere else. It moves on
82+
its own schedule and an application usually pins its own, which
83+
is the other reason the Arrow reader is an artifact of its own
84+
rather than a package in the client. -->
85+
<arrow.version>19.0.0</arrow.version>
86+
8087
<junit.version>6.1.3</junit.version>
8188
<maven.compiler.plugin.version>3.15.0</maven.compiler.plugin.version>
8289
<maven.surefire.plugin.version>3.5.6</maven.surefire.plugin.version>
@@ -96,6 +103,13 @@
96103
<artifactId>zudb</artifactId>
97104
<version>${project.version}</version>
98105
</dependency>
106+
<dependency>
107+
<groupId>org.apache.arrow</groupId>
108+
<artifactId>arrow-bom</artifactId>
109+
<version>${arrow.version}</version>
110+
<type>pom</type>
111+
<scope>import</scope>
112+
</dependency>
99113
<dependency>
100114
<groupId>org.junit</groupId>
101115
<artifactId>junit-bom</artifactId>

zudb-arrow/pom.xml

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!--
3+
The Arrow reader. Thirty lines over arrow-java, because the work
4+
happens on the other side of the C Data Interface.
5+
6+
It is a separate artifact so that a program with no use for Arrow does
7+
not carry arrow-java, which is the largest dependency anything in this
8+
repository would have and the one most likely to clash with a version
9+
an application already pins.
10+
-->
11+
<project xmlns="http://maven.apache.org/POM/4.0.0"
12+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
13+
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
14+
<modelVersion>4.0.0</modelVersion>
15+
16+
<parent>
17+
<groupId>dev.zudb</groupId>
18+
<artifactId>zudb-parent</artifactId>
19+
<version>0.11.0-SNAPSHOT</version>
20+
</parent>
21+
22+
<artifactId>zudb-arrow</artifactId>
23+
<name>zu for the JVM: Arrow</name>
24+
<description>A zu result as an Arrow reader, over the C Data Interface, without a copy.</description>
25+
26+
<dependencies>
27+
<dependency>
28+
<groupId>dev.zudb</groupId>
29+
<artifactId>zudb</artifactId>
30+
</dependency>
31+
<dependency>
32+
<groupId>org.apache.arrow</groupId>
33+
<artifactId>arrow-c-data</artifactId>
34+
</dependency>
35+
<dependency>
36+
<groupId>org.apache.arrow</groupId>
37+
<artifactId>arrow-vector</artifactId>
38+
</dependency>
39+
<dependency>
40+
<groupId>org.apache.arrow</groupId>
41+
<artifactId>arrow-memory-core</artifactId>
42+
</dependency>
43+
44+
<!-- The provider and an allocator, both of which an application
45+
picks for itself and neither of which this module should force
46+
on it. The tests need one of each to run at all. -->
47+
<dependency>
48+
<groupId>dev.zudb</groupId>
49+
<artifactId>zudb-ffm</artifactId>
50+
<version>${project.version}</version>
51+
<scope>test</scope>
52+
</dependency>
53+
<dependency>
54+
<groupId>org.apache.arrow</groupId>
55+
<artifactId>arrow-memory-unsafe</artifactId>
56+
<scope>test</scope>
57+
</dependency>
58+
</dependencies>
59+
60+
<build>
61+
<plugins>
62+
<plugin>
63+
<groupId>org.apache.maven.plugins</groupId>
64+
<artifactId>maven-compiler-plugin</artifactId>
65+
<configuration>
66+
<release>${zu.release.api}</release>
67+
</configuration>
68+
<executions>
69+
<execution>
70+
<id>default-testCompile</id>
71+
<configuration>
72+
<!-- One lint off, and only here. Arrow's own classes are
73+
annotated with checkerframework's, which arrow ships
74+
as provided and nobody downstream has on a classpath,
75+
so javac reports an annotation it cannot read every
76+
time a test touches an allocator. It is a warning
77+
about arrow's build rather than about ours, and -Werror
78+
would otherwise make it a failure. -->
79+
<compilerArgs>
80+
<arg>-Xlint:all,-requires-automatic,-requires-transitive-automatic,-classfile</arg>
81+
<arg>-Werror</arg>
82+
</compilerArgs>
83+
</configuration>
84+
</execution>
85+
</executions>
86+
</plugin>
87+
<plugin>
88+
<groupId>org.apache.maven.plugins</groupId>
89+
<artifactId>maven-surefire-plugin</artifactId>
90+
<configuration>
91+
<!-- The tests link against the engine through the Panama
92+
provider, so they need the grant it needs, and arrow's
93+
allocator reaches into java.nio, so they need that opened
94+
to it. Both are what an application running this stack
95+
passes, and the README says so. -->
96+
<useModulePath>false</useModulePath>
97+
<argLine>--enable-native-access=ALL-UNNAMED --add-opens=java.base/java.nio=ALL-UNNAMED --sun-misc-unsafe-memory-access=allow ${zu.test.args}</argLine>
98+
</configuration>
99+
</plugin>
100+
</plugins>
101+
</build>
102+
</project>
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package dev.zudb.arrow;
2+
3+
import dev.zudb.Connection;
4+
import dev.zudb.Result;
5+
import java.util.Objects;
6+
import org.apache.arrow.c.ArrowArrayStream;
7+
import org.apache.arrow.c.Data;
8+
import org.apache.arrow.memory.BufferAllocator;
9+
import org.apache.arrow.vector.ipc.ArrowReader;
10+
11+
/**
12+
* A result as Arrow, without a copy on the way.
13+
*
14+
* <pre>{@code
15+
* try (BufferAllocator allocator = new RootAllocator();
16+
* ArrowReader reader = Arrow.query(allocator, conn, "MATCH (p:Person) RETURN p.id AS id")) {
17+
* while (reader.loadNextBatch()) {
18+
* BigIntVector ids = (BigIntVector) reader.getVectorSchemaRoot().getVector(0);
19+
* for (int i = 0; i < ids.getValueCount(); i++) {
20+
* sum += ids.get(i);
21+
* }
22+
* }
23+
* }
24+
* }</pre>
25+
*
26+
* <p>Nothing on this path is proportional to the answer. The arrays that cross
27+
* are the buffers the engine's executor filled, at the addresses it filled
28+
* them at, and what an export costs is the schema, the stream and the pointers
29+
* in it. A million rows and ten thousand cost about the same.
30+
*
31+
* <p>That is also why an export spends the result. Once the buffers have left,
32+
* there is nothing on this side to read a second time, so the {@code Result}
33+
* handed to any of these is closed by the call and every buffer a columnar
34+
* reader borrowed from it before now belongs to the Arrow consumer. Closing it
35+
* again afterwards is the no-op it always was, so a try-with-resources around
36+
* it is still the right shape.
37+
*
38+
* <p>The reader owns what it was given and releases the stream when it closes,
39+
* which releases the result the stream was made from. Close the reader.
40+
*
41+
* <p>A result the engine had to build across its rows, which is anything with
42+
* an {@code ORDER BY}, has no buffers to move and is read into buffers of its
43+
* own on the way out. That is the fallback working rather than the fast path
44+
* failing, and it is still one pass and still correct.
45+
*/
46+
public final class Arrow {
47+
48+
private Arrow() {}
49+
50+
/**
51+
* Runs a statement and hands back its answer as Arrow.
52+
*
53+
* @param allocator what the Arrow side allocates from
54+
* @param conn the connection
55+
* @param statement the text
56+
* @return the reader, which the caller closes
57+
*/
58+
public static ArrowReader query(BufferAllocator allocator, Connection conn, String statement) {
59+
Objects.requireNonNull(conn, "conn");
60+
Result result = conn.query(statement);
61+
try {
62+
return reader(allocator, result);
63+
} catch (RuntimeException | Error e) {
64+
result.close();
65+
throw e;
66+
}
67+
}
68+
69+
/**
70+
* A result already in hand, as Arrow, in batches of {@link
71+
* Result#DEFAULT_BATCH} rows.
72+
*
73+
* @param allocator what the Arrow side allocates from
74+
* @param result the result, which this call spends
75+
* @return the reader, which the caller closes
76+
*/
77+
public static ArrowReader reader(BufferAllocator allocator, Result result) {
78+
return reader(allocator, result, 0);
79+
}
80+
81+
/**
82+
* The same, with the batch size named.
83+
*
84+
* @param allocator what the Arrow side allocates from
85+
* @param result the result, which this call spends
86+
* @param rowsPerBatch how many rows a consumer sees at a time, or zero for
87+
* {@link Result#DEFAULT_BATCH}. The batches are slices of arrays that
88+
* are already in memory, so this is about what a consumer likes to work
89+
* in and not about what gets allocated
90+
* @return the reader, which the caller closes
91+
*/
92+
public static ArrowReader reader(BufferAllocator allocator, Result result, long rowsPerBatch) {
93+
Objects.requireNonNull(allocator, "allocator");
94+
Objects.requireNonNull(result, "result");
95+
ArrowArrayStream stream = ArrowArrayStream.allocateNew(allocator);
96+
try {
97+
result.exportArrow(stream.memoryAddress(), rowsPerBatch);
98+
return Data.importArrayStream(allocator, stream);
99+
} catch (RuntimeException | Error e) {
100+
// A refusal leaves the struct as it was allocated, which is
101+
// released, so this frees the memory it sits in and calls nothing.
102+
// An import that failed leaves a live stream, and this is what
103+
// releases it.
104+
stream.close();
105+
throw e;
106+
}
107+
}
108+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/**
2+
* A zu result as Arrow, over the C Data Interface.
3+
*
4+
* <p>One class, {@link dev.zudb.arrow.Arrow}, and three static methods on it.
5+
* Everything else a program needs on this path is arrow-java's own, because
6+
* what comes back is an {@link org.apache.arrow.vector.ipc.ArrowReader} and
7+
* every Arrow consumer on the JVM already takes one.
8+
*
9+
* <p>This lives in an artifact of its own so that the client keeps its
10+
* dependencies at none. A program reading rows or columns has no reason to
11+
* carry arrow-java, and a program that wants Arrow adds one line to a build
12+
* file.
13+
*/
14+
package dev.zudb.arrow;
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/**
2+
* A zu result as an Arrow reader.
3+
*
4+
* <p>This module is where arrow-java is named and the only place in this
5+
* client that names it. A program that reads rows or columns depends on {@code
6+
* dev.zudb} and carries nothing of Arrow; a program that wants Arrow adds this
7+
* and gets the reader every Arrow consumer on the JVM already takes.
8+
*/
9+
module dev.zudb.arrow {
10+
// Transitive, all three of them, because they are the types on the
11+
// three methods this module has: a caller passes an allocator and a
12+
// result and is handed a reader, so a caller that reads this module
13+
// reads those as well or cannot call it at all.
14+
requires transitive dev.zudb;
15+
requires transitive org.apache.arrow.memory.core;
16+
requires transitive org.apache.arrow.vector;
17+
18+
requires org.apache.arrow.c;
19+
20+
exports dev.zudb.arrow;
21+
}

0 commit comments

Comments
 (0)