Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ jobs:
# gets exactly this artifact and the JNI provider beside it.
- run: mvn $MAVEN_ARGS -pl zudb -am test

# The Arrow reader is a 17 artifact as well, and the README says so
# in a table. This is what keeps that true. Its tests need the FFM
# provider to run against, which this JDK cannot build, so what is
# checked here is that the sources a 17 caller compiles against
# compile on 17.
- run: mvn $MAVEN_ARGS -pl zudb -am install -DskipTests
- run: mvn $MAVEN_ARGS -pl zudb-arrow compile

# The whole client against the engine at its own HEAD, which is what
# makes a red job here mean the binding is wrong about the ABI rather
# than that a checked-in copy of something is stale.
Expand Down
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,47 @@ What it is worth, summing one integer column of a hundred thousand rows on an M-

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.

## Handing the whole result to Arrow

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:

```xml
<dependency>
<groupId>dev.zudb</groupId>
<artifactId>zudb-arrow</artifactId>
<version>${zu.version}</version>
</dependency>
```

```java
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 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.

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.

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.

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.

The same hundred thousand rows, statement included this time 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, 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.

## Getting rows in

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.
Expand Down Expand Up @@ -229,6 +270,7 @@ An SDK that requires a recent JDK in 2026 excludes a large part of the enterpris
| `dev.zudb:zudb` | Java 17 | the API, no native code, no FFM types in the public surface |
| `dev.zudb:zudb-ffm` | Java 25 | the FFM provider, selected automatically |
| `dev.zudb:zudb-jni` | Java 17 | the fallback provider |
| `dev.zudb:zudb-arrow` | Java 17 | the Arrow reader, the only artifact that names arrow-java |
| `dev.zudb:zudb-native` | | the `libzu` binaries, all platforms or one by classifier |

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.
Expand Down
14 changes: 14 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
<modules>
<module>zudb</module>
<module>zudb-ffm</module>
<module>zudb-arrow</module>
<module>zudb-bench</module>
</modules>

Expand Down Expand Up @@ -77,6 +78,12 @@
restating the flags the suite cannot run without. -->
<zu.test.args></zu.test.args>

<!-- arrow-java, named in one module and nowhere else. It moves on
its own schedule and an application usually pins its own, which
is the other reason the Arrow reader is an artifact of its own
rather than a package in the client. -->
<arrow.version>19.0.0</arrow.version>

<junit.version>6.1.3</junit.version>
<maven.compiler.plugin.version>3.15.0</maven.compiler.plugin.version>
<maven.surefire.plugin.version>3.5.6</maven.surefire.plugin.version>
Expand All @@ -96,6 +103,13 @@
<artifactId>zudb</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.apache.arrow</groupId>
<artifactId>arrow-bom</artifactId>
<version>${arrow.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.junit</groupId>
<artifactId>junit-bom</artifactId>
Expand Down
102 changes: 102 additions & 0 deletions zudb-arrow/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
The Arrow reader. Thirty lines over arrow-java, because the work
happens on the other side of the C Data Interface.

It is a separate artifact so that a program with no use for Arrow does
not carry arrow-java, which is the largest dependency anything in this
repository would have and the one most likely to clash with a version
an application already pins.
-->
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<parent>
<groupId>dev.zudb</groupId>
<artifactId>zudb-parent</artifactId>
<version>0.11.0-SNAPSHOT</version>
</parent>

<artifactId>zudb-arrow</artifactId>
<name>zu for the JVM: Arrow</name>
<description>A zu result as an Arrow reader, over the C Data Interface, without a copy.</description>

<dependencies>
<dependency>
<groupId>dev.zudb</groupId>
<artifactId>zudb</artifactId>
</dependency>
<dependency>
<groupId>org.apache.arrow</groupId>
<artifactId>arrow-c-data</artifactId>
</dependency>
<dependency>
<groupId>org.apache.arrow</groupId>
<artifactId>arrow-vector</artifactId>
</dependency>
<dependency>
<groupId>org.apache.arrow</groupId>
<artifactId>arrow-memory-core</artifactId>
</dependency>

<!-- The provider and an allocator, both of which an application
picks for itself and neither of which this module should force
on it. The tests need one of each to run at all. -->
<dependency>
<groupId>dev.zudb</groupId>
<artifactId>zudb-ffm</artifactId>
<version>${project.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.apache.arrow</groupId>
<artifactId>arrow-memory-unsafe</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<release>${zu.release.api}</release>
</configuration>
<executions>
<execution>
<id>default-testCompile</id>
<configuration>
<!-- One lint off, and only here. Arrow's own classes are
annotated with checkerframework's, which arrow ships
as provided and nobody downstream has on a classpath,
so javac reports an annotation it cannot read every
time a test touches an allocator. It is a warning
about arrow's build rather than about ours, and -Werror
would otherwise make it a failure. -->
<compilerArgs>
<arg>-Xlint:all,-requires-automatic,-requires-transitive-automatic,-classfile</arg>
<arg>-Werror</arg>
</compilerArgs>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<!-- The tests link against the engine through the Panama
provider, so they need the grant it needs, and arrow's
allocator reaches into java.nio, so they need that opened
to it. Both are what an application running this stack
passes, and the README says so. -->
<useModulePath>false</useModulePath>
<argLine>--enable-native-access=ALL-UNNAMED --add-opens=java.base/java.nio=ALL-UNNAMED --sun-misc-unsafe-memory-access=allow ${zu.test.args}</argLine>
</configuration>
</plugin>
</plugins>
</build>
</project>
108 changes: 108 additions & 0 deletions zudb-arrow/src/main/java/dev/zudb/arrow/Arrow.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package dev.zudb.arrow;

import dev.zudb.Connection;
import dev.zudb.Result;
import java.util.Objects;
import org.apache.arrow.c.ArrowArrayStream;
import org.apache.arrow.c.Data;
import org.apache.arrow.memory.BufferAllocator;
import org.apache.arrow.vector.ipc.ArrowReader;

/**
* A result as Arrow, without a copy on the way.
*
* <pre>{@code
* 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(0);
* for (int i = 0; i < ids.getValueCount(); i++) {
* sum += ids.get(i);
* }
* }
* }
* }</pre>
*
* <p>Nothing on this path is proportional to the answer. The arrays that cross
* are the buffers the engine's executor filled, at the addresses it filled
* them at, and what an export costs is the schema, the stream and the pointers
* in it. A million rows and ten thousand cost about the same.
*
* <p>That is also why an export spends the result. Once the buffers have left,
* there is nothing on this side to read a second time, so the {@code Result}
* handed to any of these is closed by the call and every buffer a columnar
* reader borrowed from it before now belongs to the Arrow consumer. Closing it
* again afterwards is the no-op it always was, so a try-with-resources around
* it is still the right shape.
*
* <p>The reader owns what it was given and releases the stream when it closes,
* which releases the result the stream was made from. Close the reader.
*
* <p>A result the engine had to build across its rows, which is anything with
* an {@code ORDER BY}, has no buffers to move and is read into buffers of its
* own on the way out. That is the fallback working rather than the fast path
* failing, and it is still one pass and still correct.
*/
public final class Arrow {

private Arrow() {}

/**
* Runs a statement and hands back its answer as Arrow.
*
* @param allocator what the Arrow side allocates from
* @param conn the connection
* @param statement the text
* @return the reader, which the caller closes
*/
public static ArrowReader query(BufferAllocator allocator, Connection conn, String statement) {
Objects.requireNonNull(conn, "conn");
Result result = conn.query(statement);
try {
return reader(allocator, result);
} catch (RuntimeException | Error e) {
result.close();
throw e;
}
}

/**
* A result already in hand, as Arrow, in batches of {@link
* Result#DEFAULT_BATCH} rows.
*
* @param allocator what the Arrow side allocates from
* @param result the result, which this call spends
* @return the reader, which the caller closes
*/
public static ArrowReader reader(BufferAllocator allocator, Result result) {
return reader(allocator, result, 0);
}

/**
* The same, with the batch size named.
*
* @param allocator what the Arrow side allocates from
* @param result the result, which this call spends
* @param rowsPerBatch how many rows a consumer sees at a time, or zero for
* {@link Result#DEFAULT_BATCH}. The batches are slices of arrays that
* are already in memory, so this is about what a consumer likes to work
* in and not about what gets allocated
* @return the reader, which the caller closes
*/
public static ArrowReader reader(BufferAllocator allocator, Result result, long rowsPerBatch) {
Objects.requireNonNull(allocator, "allocator");
Objects.requireNonNull(result, "result");
ArrowArrayStream stream = ArrowArrayStream.allocateNew(allocator);
try {
result.exportArrow(stream.memoryAddress(), rowsPerBatch);
return Data.importArrayStream(allocator, stream);
} catch (RuntimeException | Error e) {
// A refusal leaves the struct as it was allocated, which is
// released, so this frees the memory it sits in and calls nothing.
// An import that failed leaves a live stream, and this is what
// releases it.
stream.close();
throw e;
}
}
}
14 changes: 14 additions & 0 deletions zudb-arrow/src/main/java/dev/zudb/arrow/package-info.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* A zu result as Arrow, over the C Data Interface.
*
* <p>One class, {@link dev.zudb.arrow.Arrow}, and three static methods on it.
* Everything else a program needs on this path is arrow-java's own, because
* what comes back is an {@link org.apache.arrow.vector.ipc.ArrowReader} and
* every Arrow consumer on the JVM already takes one.
*
* <p>This lives in an artifact of its own so that the client keeps its
* dependencies at none. A program reading rows or columns has no reason to
* carry arrow-java, and a program that wants Arrow adds one line to a build
* file.
*/
package dev.zudb.arrow;
21 changes: 21 additions & 0 deletions zudb-arrow/src/main/java/module-info.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* A zu result as an Arrow reader.
*
* <p>This module is where arrow-java is named and the only place in this
* client that names it. A program that reads rows or columns depends on {@code
* dev.zudb} and carries nothing of Arrow; a program that wants Arrow adds this
* and gets the reader every Arrow consumer on the JVM already takes.
*/
module dev.zudb.arrow {
// Transitive, all three of them, because they are the types on the
// three methods this module has: a caller passes an allocator and a
// result and is handed a reader, so a caller that reads this module
// reads those as well or cannot call it at all.
requires transitive dev.zudb;
requires transitive org.apache.arrow.memory.core;
requires transitive org.apache.arrow.vector;

requires org.apache.arrow.c;

exports dev.zudb.arrow;
}
Loading
Loading