From bec7fab7126790690f6d4829944532d92eb7ddb9 Mon Sep 17 00:00:00 2001 From: Tam Nguyen Duc <1218621+tamnd@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:31:07 +0700 Subject: [PATCH 1/2] 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. --- .github/workflows/ci.yml | 8 + README.md | 42 +++ pom.xml | 14 + zudb-arrow/pom.xml | 102 ++++++ .../src/main/java/dev/zudb/arrow/Arrow.java | 108 ++++++ .../java/dev/zudb/arrow/package-info.java | 14 + zudb-arrow/src/main/java/module-info.java | 21 ++ .../test/java/dev/zudb/arrow/ArrowTest.java | 329 ++++++++++++++++++ .../src/test/java/dev/zudb/arrow/Libzu.java | 61 ++++ zudb-bench/pom.xml | 13 + .../main/java/dev/zudb/bench/ArrowBench.java | 147 ++++++++ zudb-ffm/src/main/java/dev/zudb/ffm/Abi.java | 7 + .../main/java/dev/zudb/ffm/FfmBinding.java | 26 ++ zudb/src/main/java/dev/zudb/Connection.java | 17 +- zudb/src/main/java/dev/zudb/Result.java | 101 +++++- zudb/src/main/java/dev/zudb/Statement.java | 6 +- .../src/main/java/dev/zudb/spi/ZuBinding.java | 26 ++ 17 files changed, 1029 insertions(+), 13 deletions(-) create mode 100644 zudb-arrow/pom.xml create mode 100644 zudb-arrow/src/main/java/dev/zudb/arrow/Arrow.java create mode 100644 zudb-arrow/src/main/java/dev/zudb/arrow/package-info.java create mode 100644 zudb-arrow/src/main/java/module-info.java create mode 100644 zudb-arrow/src/test/java/dev/zudb/arrow/ArrowTest.java create mode 100644 zudb-arrow/src/test/java/dev/zudb/arrow/Libzu.java create mode 100644 zudb-bench/src/main/java/dev/zudb/bench/ArrowBench.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be18e27..bcc9e7d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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. diff --git a/README.md b/README.md index 1cf2446..739b235 100644 --- a/README.md +++ b/README.md @@ -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 + + dev.zudb + zudb-arrow + ${zu.version} + +``` + +```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. @@ -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. diff --git a/pom.xml b/pom.xml index fec4ff0..288138a 100644 --- a/pom.xml +++ b/pom.xml @@ -50,6 +50,7 @@ zudb zudb-ffm + zudb-arrow zudb-bench @@ -77,6 +78,12 @@ restating the flags the suite cannot run without. --> + + 19.0.0 + 6.1.3 3.15.0 3.5.6 @@ -96,6 +103,13 @@ zudb ${project.version} + + org.apache.arrow + arrow-bom + ${arrow.version} + pom + import + org.junit junit-bom diff --git a/zudb-arrow/pom.xml b/zudb-arrow/pom.xml new file mode 100644 index 0000000..34bde75 --- /dev/null +++ b/zudb-arrow/pom.xml @@ -0,0 +1,102 @@ + + + + 4.0.0 + + + dev.zudb + zudb-parent + 0.11.0-SNAPSHOT + + + zudb-arrow + zu for the JVM: Arrow + A zu result as an Arrow reader, over the C Data Interface, without a copy. + + + + dev.zudb + zudb + + + org.apache.arrow + arrow-c-data + + + org.apache.arrow + arrow-vector + + + org.apache.arrow + arrow-memory-core + + + + + dev.zudb + zudb-ffm + ${project.version} + test + + + org.apache.arrow + arrow-memory-unsafe + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + ${zu.release.api} + + + + default-testCompile + + + + -Xlint:all,-requires-automatic,-requires-transitive-automatic,-classfile + -Werror + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + false + --enable-native-access=ALL-UNNAMED --add-opens=java.base/java.nio=ALL-UNNAMED --sun-misc-unsafe-memory-access=allow ${zu.test.args} + + + + + diff --git a/zudb-arrow/src/main/java/dev/zudb/arrow/Arrow.java b/zudb-arrow/src/main/java/dev/zudb/arrow/Arrow.java new file mode 100644 index 0000000..f975d26 --- /dev/null +++ b/zudb-arrow/src/main/java/dev/zudb/arrow/Arrow.java @@ -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. + * + *
{@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);
+ *     }
+ *   }
+ * }
+ * }
+ * + *

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. + * + *

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. + * + *

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. + * + *

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; + } + } +} diff --git a/zudb-arrow/src/main/java/dev/zudb/arrow/package-info.java b/zudb-arrow/src/main/java/dev/zudb/arrow/package-info.java new file mode 100644 index 0000000..f489236 --- /dev/null +++ b/zudb-arrow/src/main/java/dev/zudb/arrow/package-info.java @@ -0,0 +1,14 @@ +/** + * A zu result as Arrow, over the C Data Interface. + * + *

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. + * + *

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; diff --git a/zudb-arrow/src/main/java/module-info.java b/zudb-arrow/src/main/java/module-info.java new file mode 100644 index 0000000..5597237 --- /dev/null +++ b/zudb-arrow/src/main/java/module-info.java @@ -0,0 +1,21 @@ +/** + * A zu result as an Arrow reader. + * + *

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; +} diff --git a/zudb-arrow/src/test/java/dev/zudb/arrow/ArrowTest.java b/zudb-arrow/src/test/java/dev/zudb/arrow/ArrowTest.java new file mode 100644 index 0000000..7746652 --- /dev/null +++ b/zudb-arrow/src/test/java/dev/zudb/arrow/ArrowTest.java @@ -0,0 +1,329 @@ +package dev.zudb.arrow; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.zudb.Connection; +import dev.zudb.Database; +import dev.zudb.Loader; +import dev.zudb.Result; +import dev.zudb.Statement; +import dev.zudb.Value; +import dev.zudb.ZuClosedException; +import dev.zudb.ZuException; +import dev.zudb.ZuProgrammingException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import org.apache.arrow.memory.BufferAllocator; +import org.apache.arrow.memory.RootAllocator; +import org.apache.arrow.vector.BigIntVector; +import org.apache.arrow.vector.Float8Vector; +import org.apache.arrow.vector.VarCharVector; +import org.apache.arrow.vector.VectorSchemaRoot; +import org.apache.arrow.vector.complex.StructVector; +import org.apache.arrow.vector.ipc.ArrowReader; +import org.apache.arrow.vector.types.pojo.ArrowType; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * A result crossing into Arrow, over the C Data Interface. + * + *

What these are for is that the values arrive intact, that the batching is + * what was asked for, and that the result is spent exactly when the engine + * spends it: after an export, whether the export worked or not, and not before + * a call this client refused on its own. + * + *

The allocator is closed after every test, which fails the test if a buffer + * was left behind, so leaking the stream or the reader is caught here rather + * than showing up as memory a long-running program never gets back. + */ +class ArrowTest { + + @TempDir Path dir; + + private BufferAllocator allocator; + + @BeforeAll + static void engine() { + Libzu.require(); + } + + @BeforeEach + void allocator() { + allocator = new RootAllocator(); + } + + @AfterEach + void balanced() { + // Closing a RootAllocator with anything outstanding throws, so this is + // the assertion that every reader above released what it was handed. + allocator.close(); + } + + @Test + void aStoredColumnCrossesAsArrow() throws Exception { + Path path = dir.resolve("people.zu"); + try (Loader loader = Loader.create(path)) { + loader.table("Person", "Knows", 3); + loader.column("id", 1L, 2L, 3L); + loader.column("name", "ada", "grace", "alan"); + loader.finish(); + } + + // A projection of stored values with nothing above it, which is the plan + // whose columns the executor fills. The arrays that cross are those + // buffers, so nothing here is proportional to the row count. + try (Database db = Database.open(path); + Connection conn = db.connect(); + ArrowReader reader = + Arrow.query(allocator, conn, "MATCH (p:Person) RETURN p.id AS id, p.name AS name")) { + VectorSchemaRoot root = reader.getVectorSchemaRoot(); + assertEquals(List.of("id", "name"), root.getSchema().getFields().stream().map(f -> f.getName()).toList()); + + List ids = new ArrayList<>(); + List names = new ArrayList<>(); + while (reader.loadNextBatch()) { + BigIntVector id = (BigIntVector) root.getVector("id"); + VarCharVector name = (VarCharVector) root.getVector("name"); + for (int i = 0; i < root.getRowCount(); i++) { + ids.add(id.get(i)); + names.add(new String(name.get(i), StandardCharsets.UTF_8)); + } + } + assertEquals(List.of(1L, 2L, 3L), ids); + assertEquals(List.of("ada", "grace", "alan"), names); + } + } + + @Test + void aRowBuiltResultCrossesThroughTheFallback() throws Exception { + Path path = dir.resolve("ordered.zu"); + try (Loader loader = Loader.create(path)) { + loader.table("Person", "Knows", 3); + loader.column("id", 2L, 3L, 1L); + loader.finish(); + } + + // An ORDER BY leaves the engine with rows rather than columns, so this is + // the other path out: read into buffers on the way rather than handing + // over ones that already existed. Same answer, and the caller cannot tell + // which happened except by timing it. + try (Database db = Database.open(path); + Connection conn = db.connect(); + ArrowReader reader = + Arrow.query(allocator, conn, "MATCH (p:Person) RETURN p.id AS id ORDER BY p.id")) { + List ids = new ArrayList<>(); + while (reader.loadNextBatch()) { + BigIntVector id = (BigIntVector) reader.getVectorSchemaRoot().getVector("id"); + for (int i = 0; i < id.getValueCount(); i++) { + ids.add(id.get(i)); + } + } + assertEquals(List.of(1L, 2L, 3L), ids); + } + } + + @Test + void aNullIsANullAndNotAZero() throws Exception { + try (Database db = Database.memory(); + Connection conn = db.connect(); + ArrowReader reader = + Arrow.query(allocator, conn, "UNWIND [1, null, 3] AS n RETURN n, n * 1.5 AS f")) { + long batches = 0; + while (reader.loadNextBatch()) { + batches++; + VectorSchemaRoot root = reader.getVectorSchemaRoot(); + BigIntVector n = (BigIntVector) root.getVector("n"); + Float8Vector f = (Float8Vector) root.getVector("f"); + assertEquals(3, root.getRowCount()); + assertEquals(1L, n.get(0)); + assertTrue(n.isNull(1), "the validity bitmap is the only place a null lives"); + assertEquals(3L, n.get(2)); + assertEquals(1.5, f.get(0)); + assertTrue(f.isNull(1)); + assertEquals(4.5, f.get(2)); + } + assertEquals(1, batches); + } + } + + @Test + void aStringColumnArrivesAsUtf8WithItsHolesIntact() throws Exception { + try (Database db = Database.memory(); + Connection conn = db.connect(); + ArrowReader reader = + Arrow.query(allocator, conn, "UNWIND ['ada', null, '🐍'] AS s RETURN s")) { + List strings = new ArrayList<>(); + while (reader.loadNextBatch()) { + VarCharVector s = (VarCharVector) reader.getVectorSchemaRoot().getVector("s"); + for (int i = 0; i < s.getValueCount(); i++) { + strings.add(s.isNull(i) ? null : new String(s.get(i), StandardCharsets.UTF_8)); + } + } + assertEquals(java.util.Arrays.asList("ada", null, "🐍"), strings); + } + } + + @Test + void aNodeColumnNamesItsTableOutOfTheCatalogTheConnectionHolds() throws Exception { + Path path = dir.resolve("nodes.zu"); + try (Loader loader = Loader.create(path)) { + loader.table("Person", "Knows", 2); + loader.column("id", 1L, 2L); + loader.finish(); + } + + try (Database db = Database.open(path); + Connection conn = db.connect(); + ArrowReader reader = Arrow.query(allocator, conn, "MATCH (p:Person) RETURN p AS n")) { + List tables = new ArrayList<>(); + while (reader.loadNextBatch()) { + StructVector n = (StructVector) reader.getVectorSchemaRoot().getVector("n"); + VarCharVector table = n.getChild("table", VarCharVector.class); + for (int i = 0; i < n.getValueCount(); i++) { + tables.add(new String(table.get(i), StandardCharsets.UTF_8)); + } + } + assertEquals(List.of("Person", "Person"), tables); + } + } + + @Test + void theBatchSizeIsWhatTheConsumerAskedFor() throws Exception { + Path path = dir.resolve("many.zu"); + long[] ids = new long[3000]; + for (int i = 0; i < ids.length; i++) { + ids[i] = i; + } + try (Loader loader = Loader.create(path)) { + loader.table("Row", "Near", ids.length); + loader.column("id", ids); + loader.finish(); + } + + try (Database db = Database.open(path); + Connection conn = db.connect(); + Result r = conn.query("MATCH (x:Row) RETURN x.id AS id"); + ArrowReader reader = Arrow.reader(allocator, r, 1000)) { + List sizes = new ArrayList<>(); + long total = 0; + while (reader.loadNextBatch()) { + VectorSchemaRoot root = reader.getVectorSchemaRoot(); + sizes.add(root.getRowCount()); + BigIntVector id = (BigIntVector) root.getVector("id"); + for (int i = 0; i < root.getRowCount(); i++) { + total += id.get(i); + } + } + // Three batches of a thousand, and the batches are slices of arrays + // that were already in memory rather than three copies of anything. + assertEquals(List.of(1000, 1000, 1000), sizes); + assertEquals(3000L * 2999 / 2, total); + } + } + + @Test + void aResultWithNoRowsCrossesAsOneEmptyBatch() throws Exception { + try (Database db = Database.memory(); + Connection conn = db.connect(); + ArrowReader reader = Arrow.query(allocator, conn, "UNWIND [] AS v RETURN v")) { + assertEquals(1, reader.getVectorSchemaRoot().getSchema().getFields().size()); + // One batch of nothing rather than nothing at all, so that a consumer + // reading batches and not the schema still learns what the columns were + // going to be. + assertTrue(reader.loadNextBatch()); + assertEquals(0, reader.getVectorSchemaRoot().getRowCount()); + assertFalse(reader.loadNextBatch()); + } + } + + @Test + void theExportSpendsTheResult() throws Exception { + try (Database db = Database.memory(); + Connection conn = db.connect()) { + Result r = conn.query("UNWIND [1, 2] AS v RETURN v"); + assertFalse(r.isClosed()); + try (ArrowReader reader = Arrow.reader(allocator, r)) { + assertTrue(r.isClosed(), "the buffers have left, so there is nothing here to read again"); + assertThrows(ZuClosedException.class, () -> r.row(0).getLong(0)); + // Closing it is still the right shape to write, and still a no-op. + r.close(); + assertTrue(reader.loadNextBatch()); + assertEquals(2, reader.getVectorSchemaRoot().getRowCount()); + } + } + } + + @Test + void aStreamThatIsNowhereIsRefusedBeforeTheEngineSeesIt() { + try (Database db = Database.memory(); + Connection conn = db.connect(); + Result r = conn.query("UNWIND [1, 2] AS v RETURN v")) { + assertThrows(ZuProgrammingException.class, () -> r.exportArrow(0)); + assertThrows(ZuProgrammingException.class, () -> r.exportArrow(1024, -1)); + // Nothing was handed over, so nothing was spent, and the result is + // still there to read the ordinary way. + assertFalse(r.isClosed()); + assertEquals(1L, r.row(0).getLong(0)); + } + } + + @Test + void aColumnArrowHasNoTypeForIsRefusedAndSpendsTheResult() { + try (Database db = Database.memory(); + Connection conn = db.connect(); + Statement stmt = conn.prepare("RETURN $v AS v")) { + // A time of day with an offset and no date to apply it to. Arrow has a + // timestamp with a zone and a time without one, and nothing in between, + // so this is a column that cannot cross and the refusal names it. + stmt.bind("v", Value.Temporal.Kind.ZONED_TIME, 45_296_000_000_000L, 420); + Result r = stmt.execute(); + ZuException e = assertThrows(ZuException.class, () -> Arrow.reader(allocator, r)); + assertTrue(e.getMessage().contains("v"), e.getMessage()); + // The engine nulls the result on every path it takes, this one + // included, so a caller who fixes the statement cannot hand the same + // handle over twice. + assertTrue(r.isClosed()); + r.close(); + } + } + + @Test + void aClosedResultIsRefusedRatherThanExported() { + try (Database db = Database.memory(); + Connection conn = db.connect()) { + Result r = conn.query("UNWIND [1] AS v RETURN v"); + r.close(); + assertThrows(ZuClosedException.class, () -> Arrow.reader(allocator, r)); + } + } + + @Test + void theSchemaSaysWhatTheColumnsHold() throws Exception { + try (Database db = Database.memory(); + Connection conn = db.connect(); + ArrowReader reader = + Arrow.query(allocator, conn, "UNWIND [1] AS n RETURN n, 1.5 AS f, 'a' AS s, true AS b")) { + List types = + reader.getVectorSchemaRoot().getSchema().getFields().stream() + .map(f -> f.getType()) + .toList(); + assertEquals( + List.of( + new ArrowType.Int(64, true), + new ArrowType.FloatingPoint( + org.apache.arrow.vector.types.FloatingPointPrecision.DOUBLE), + ArrowType.Utf8.INSTANCE, + ArrowType.Bool.INSTANCE), + types); + } + } +} diff --git a/zudb-arrow/src/test/java/dev/zudb/arrow/Libzu.java b/zudb-arrow/src/test/java/dev/zudb/arrow/Libzu.java new file mode 100644 index 0000000..7c302c5 --- /dev/null +++ b/zudb-arrow/src/test/java/dev/zudb/arrow/Libzu.java @@ -0,0 +1,61 @@ +package dev.zudb.arrow; + +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +/** + * Whether there is a libzu to test against, and where. + * + *

The same twenty lines as the provider module's, and a copy rather than a + * shared artifact on purpose: a test fixture published so that two test suites + * can agree on where a file is would be a third artifact to version, and this + * is a question with one right answer that nobody is going to change twice. + * + *

These tests link against a real engine, so they are skipped rather than + * failed when there is not one to link against. A checkout with no build of + * the engine beside it is an ordinary state for this repository to be in, and + * a red suite for it would train everybody to ignore a red suite. + * + *

Point them at one with {@code -Dzu.library=/path/to/libzu.dylib}, or set + * {@code ZU_LIBRARY}. A sibling checkout of the engine with a release build in + * it is found on its own. + */ +final class Libzu { + + private Libzu() {} + + private static final Path FOUND = locate(); + + /** Skips the calling test when there is no engine to call. */ + static void require() { + assumeTrue(FOUND != null, "no libzu: set -Dzu.library to run these"); + if (System.getProperty("zu.library") == null) { + System.setProperty("zu.library", FOUND.toString()); + } + } + + private static Path locate() { + String named = System.getProperty("zu.library"); + if (named == null || named.isBlank()) { + named = System.getenv("ZU_LIBRARY"); + } + if (named != null && !named.isBlank()) { + Path p = Paths.get(named); + return Files.isRegularFile(p) ? p : null; + } + String name = System.mapLibraryName("zu"); + Path here = Paths.get("").toAbsolutePath(); + for (Path root = here; root != null; root = root.getParent()) { + for (String sibling : new String[] {"zu", "zu-dx", "zu-g0"}) { + Path candidate = root.resolveSibling(sibling).resolve("target/release").resolve(name); + if (Files.isRegularFile(candidate)) { + return candidate; + } + } + } + return null; + } +} diff --git a/zudb-bench/pom.xml b/zudb-bench/pom.xml index 71197b2..8e5760a 100644 --- a/zudb-bench/pom.xml +++ b/zudb-bench/pom.xml @@ -35,12 +35,25 @@ dev.zudb zudb + + dev.zudb + zudb-arrow + ${project.version} + dev.zudb zudb-ffm ${project.version} runtime + + + org.apache.arrow + arrow-memory-unsafe + runtime + org.openjdk.jmh jmh-core diff --git a/zudb-bench/src/main/java/dev/zudb/bench/ArrowBench.java b/zudb-bench/src/main/java/dev/zudb/bench/ArrowBench.java new file mode 100644 index 0000000..5a9d1fd --- /dev/null +++ b/zudb-bench/src/main/java/dev/zudb/bench/ArrowBench.java @@ -0,0 +1,147 @@ +package dev.zudb.bench; + +import dev.zudb.Connection; +import dev.zudb.Database; +import dev.zudb.Loader; +import dev.zudb.Result; +import dev.zudb.Row; +import dev.zudb.arrow.Arrow; +import java.io.IOException; +import java.nio.LongBuffer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.TimeUnit; +import org.apache.arrow.memory.BufferAllocator; +import org.apache.arrow.memory.RootAllocator; +import org.apache.arrow.vector.BigIntVector; +import org.apache.arrow.vector.ipc.ArrowReader; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OperationsPerInvocation; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Warmup; + +/** + * What handing a hundred thousand rows to Arrow costs, against reading the + * same rows here. + * + *

Every benchmark below runs the statement as well, because an export + * spends its result and there is no way to export the same one twice. That + * makes these four comparable to each other and not to {@link ReadBench}, + * which reads a result it was handed and never runs anything. + * + *

The table is on disk and the statement is a scan of stored values with + * nothing above it, which is the plan whose columns the executor fills. That + * is the case worth measuring: the arrays that cross into Arrow are those + * buffers, so the export is a schema and a handful of pointers and does not + * grow with the row count. Summing through the reader afterwards is the JVM + * walking memory the engine wrote, which is what a real consumer does and + * what makes {@code exportAndSum} more than a pointer swap. + */ +@State(Scope.Benchmark) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@Warmup(iterations = 3, time = 2) +@Measurement(iterations = 5, time = 2) +@Fork(value = 1, jvmArgs = {"--enable-native-access=ALL-UNNAMED", "--add-opens=java.base/java.nio=ALL-UNNAMED", "--sun-misc-unsafe-memory-access=allow"}) +public class ArrowBench { + + /** + * How many rows the table holds. A constant rather than a parameter because + * the per-row score is scaled by it, and JMH wants that scale as a literal + * in an annotation. + */ + private static final int ROWS = 100_000; + + private static final String SCAN = "MATCH (r:Row) RETURN r.id AS id"; + + private Path dir; + private Database db; + private Connection conn; + private BufferAllocator allocator; + + @Setup + public void build() throws IOException { + dir = Files.createTempDirectory("zu-arrow-bench"); + Path path = dir.resolve("rows.zu"); + try (Loader loader = Loader.create(path)) { + loader.table("Row", "Near", ROWS); + long[] ids = new long[ROWS]; + for (int i = 0; i < ROWS; i++) { + ids[i] = i; + } + loader.column("id", ids); + loader.finish(); + } + db = Database.open(path); + conn = db.connect(); + allocator = new RootAllocator(); + } + + @TearDown + public void close() throws IOException { + allocator.close(); + conn.close(); + db.close(); + Temp.deleteTree(dir); + } + + /** The statement and nothing else, so the three below split in two. */ + @Benchmark + @OperationsPerInvocation(ROWS) + public long queryOnly() { + try (Result r = conn.query(SCAN)) { + return r.rows(); + } + } + + /** The statement, the export, and a sum over every batch the reader gives back. */ + @Benchmark + @OperationsPerInvocation(ROWS) + public long exportAndSum() throws IOException { + long total = 0; + try (ArrowReader reader = Arrow.query(allocator, conn, SCAN)) { + while (reader.loadNextBatch()) { + BigIntVector id = (BigIntVector) reader.getVectorSchemaRoot().getVector(0); + for (int i = 0, n = id.getValueCount(); i < n; i++) { + total += id.get(i); + } + } + } + return total; + } + + /** The statement and the same sum over the borrowed column, which stays here. */ + @Benchmark + @OperationsPerInvocation(ROWS) + public long borrowAndSum() { + long total = 0; + try (Result r = conn.query(SCAN)) { + LongBuffer b = r.longs(0); + for (int i = 0, n = b.remaining(); i < n; i++) { + total += b.get(i); + } + } + return total; + } + + /** The statement and the same sum a row at a time, which is what it costs to not do either. */ + @Benchmark + @OperationsPerInvocation(ROWS) + public long rowAtATime() { + long total = 0; + try (Result r = conn.query(SCAN)) { + for (Row row : r) { + total += row.getLong(0); + } + } + return total; + } +} diff --git a/zudb-ffm/src/main/java/dev/zudb/ffm/Abi.java b/zudb-ffm/src/main/java/dev/zudb/ffm/Abi.java index 34950d4..c7467e0 100644 --- a/zudb-ffm/src/main/java/dev/zudb/ffm/Abi.java +++ b/zudb-ffm/src/main/java/dev/zudb/ffm/Abi.java @@ -113,6 +113,8 @@ final class Abi { final MethodHandle chunkColNodeOffset; final MethodHandle chunkColValid; + final MethodHandle resultArrow; + final MethodHandle loaderCreate; final MethodHandle loaderTable; final MethodHandle loaderEdges; @@ -279,6 +281,11 @@ final class Abi { "zu_result_chunk_col_valid", FunctionDescriptor.of(JAVA_INT, ADDRESS, JAVA_LONG, JAVA_INT, ADDRESS)); + resultArrow = + h( + "zu_result_arrow", + FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, JAVA_LONG, ADDRESS, ADDRESS)); + loaderCreate = h("zu_loader_create", FunctionDescriptor.of(JAVA_INT, ADDRESS, SIZE_T, ADDRESS, ADDRESS)); loaderTable = diff --git a/zudb-ffm/src/main/java/dev/zudb/ffm/FfmBinding.java b/zudb-ffm/src/main/java/dev/zudb/ffm/FfmBinding.java index d51ccb2..778cf5f 100644 --- a/zudb-ffm/src/main/java/dev/zudb/ffm/FfmBinding.java +++ b/zudb-ffm/src/main/java/dev/zudb/ffm/FfmBinding.java @@ -597,6 +597,32 @@ public ByteBuffer chunkValid(long result, long chunk, int col, long rows) { return p == 0 ? null : buffer(p, rows, 1); } + @Override + public void resultArrow(long conn, long result, long rowsPerBatch, long stream) { + Scratch s = Scratch.get(); + MemorySegment sl = s.slots(); + clear(sl); + // The engine takes the result through a pointer to it and writes null + // back, so the handle has to be somewhere it can write rather than in a + // register. The slot it lands in is read for nothing afterwards: the API + // module treats the result as gone whatever this answered, because the + // engine nulled it on the refusal path too. + sl.set(ADDRESS, OUT, ptr(result)); + try { + int st = + (int) + abi.resultArrow.invokeExact( + ptr(conn), + sl.asSlice(OUT, 8), + rowsPerBatch, + ptr(stream), + sl.asSlice(ERR, 8)); + check("zu_result_arrow", st, sl); + } catch (Throwable t) { + throw fail("zu_result_arrow", t); + } + } + @Override public long resultCell(long result, long row, int col) { Scratch s = Scratch.get(); diff --git a/zudb/src/main/java/dev/zudb/Connection.java b/zudb/src/main/java/dev/zudb/Connection.java index 0f4f14e..312c896 100644 --- a/zudb/src/main/java/dev/zudb/Connection.java +++ b/zudb/src/main/java/dev/zudb/Connection.java @@ -120,7 +120,7 @@ public static Connection memory() { * @return the result, which the caller closes */ public Result query(String statement) { - return new Result(zu, zu.query(open(), statement)); + return new Result(zu, zu.query(open(), statement), this); } /** @@ -144,7 +144,7 @@ public void execute(String statement) { * @return the statement, which the caller closes */ public Statement prepare(String statement) { - return new Statement(zu, zu.prepare(open(), statement)); + return new Statement(zu, zu.prepare(open(), statement), this); } /** @@ -435,4 +435,17 @@ private long open() { } return h; } + + /** + * The handle, or zero once this connection has closed, for the one call + * that has something to say either way. + * + *

Exporting a result to Arrow reads node table names out of the catalog + * this connection holds, and a result outlives its connection on purpose, + * so a connection that has gone is not a failure there. It costs the names, + * which the export says in its own documentation, and nothing else. + */ + long lend() { + return handle.get(); + } } diff --git a/zudb/src/main/java/dev/zudb/Result.java b/zudb/src/main/java/dev/zudb/Result.java index 4646701..9660cfa 100644 --- a/zudb/src/main/java/dev/zudb/Result.java +++ b/zudb/src/main/java/dev/zudb/Result.java @@ -25,12 +25,14 @@ * outlive is {@link #close()}, and that includes every buffer the columnar * readers handed back and every string that came out of a row. * - *

There are three ways to read it, in the order you reach for them. + *

There are four ways to read it, in the order you reach for them. * {@link #stream()} for a row at a time, which is what most code wants. * {@link #longs(int)} and the three beside it for a whole column, borrowed * from the engine rather than copied, which is what a million rows wants. * {@link #chunks()} for a whole column read a chunk at a time, which is what - * a million rows wants when you are not going to read all of them. + * a million rows wants when you are not going to read all of them. And + * {@link #exportArrow(long, long)} for handing the whole thing to something + * else entirely, which spends the result rather than reading it. * *

{@code
  * try (Result r = conn.query("MATCH (p:Person) RETURN p.name AS name")) {
@@ -40,16 +42,24 @@
  */
 public final class Result implements AutoCloseable, Iterable {
 
+  /**
+   * How many rows a consumer sees per Arrow batch when nobody names a number,
+   * which is what {@link #exportArrow(long)} asks the engine for.
+   */
+  public static final long DEFAULT_BATCH = 65536;
+
   private final ZuBinding zu;
   private final AtomicLong handle;
+  private final Connection conn;
   private final long rows;
   private final int columns;
   private final List names;
   private final Map byName;
 
-  Result(ZuBinding zu, long handle) {
+  Result(ZuBinding zu, long handle, Connection conn) {
     this.zu = zu;
     this.handle = new AtomicLong(handle);
+    this.conn = conn;
     this.rows = zu.resultRows(handle);
     this.columns = zu.resultCols(handle);
     List found = new ArrayList<>(columns);
@@ -332,12 +342,15 @@ public Chunk chunk(long index) {
   /**
    * Every chunk, in order.
    *
-   * 

Which of these to use is a question of size. A point read wants a - * whole column, because the answer is small and one call beats a loop. - * Every large answer wants chunks, because the whole-column call converts - * all of it before returning any of it and keeps the conversion until the - * result is freed: reading the first hundred rows of a million-row column - * and stopping pays for the other 999,900. + *

Which of these to use is a question of size, and only for the columns + * the engine did not fill. On those, the whole-column call converts all of + * the column before returning any of it and keeps the conversion until the + * result is freed, so reading the first hundred rows of a million-row + * column and stopping pays for the other 999,900. On a column the engine + * filled, which is every plan whose projection is a scan of stored values, + * both calls are views of the buffer it wrote and neither converts + * anything, so the choice is about the shape of the reading loop and + * nothing else. * * @return the chunks */ @@ -346,6 +359,76 @@ public Stream chunks() { return java.util.stream.LongStream.range(0, count).mapToObj(this::chunk); } + // ---- arrow ---- + + /** + * Hands the whole result to an Arrow consumer through the C Data Interface + * and spends it, in batches of {@link #DEFAULT_BATCH} rows. + * + * @param stream the address of an {@code ArrowArrayStream} the caller owns + * and has not initialised + */ + public void exportArrow(long stream) { + exportArrow(stream, 0); + } + + /** + * Hands the whole result to an Arrow consumer through the C Data Interface + * and spends it. + * + *

This is the low-level door, and most programs want {@code zudb-arrow} + * rather than this: that module wraps this call in the {@code ArrowReader} + * arrow-java already knows how to read, and it is a separate artifact so + * that a program with no use for Arrow does not carry the dependency. What + * is here is what that module needs and what a program with its own Arrow + * bindings can use instead. + * + *

The two arguments are checked here rather than by the engine, so a call + * refused for a stream that is nowhere or a batch of fewer than no rows + * handed nothing over and spent nothing: that result is still there to read + * the ordinary way, or to export once the argument is right. + * + *

Nothing on this path is a copy. The arrays that cross are the buffers + * the executor filled, at the addresses it filled them at, which is why the + * result is spent: after the buffers have left there is nothing here to + * read a second time. So this result is closed whatever the call answered, + * including a refusal, every buffer a columnar reader handed out before it + * now belongs to the Arrow consumer, and closing it again afterwards is the + * no-op it always was. + * + *

A node column names its table out of the catalog the connection holds. + * A connection that has already closed is not a failure here, and the + * export then names a table after its id, which is still an answer for a + * program that kept a result and let its connection go. + * + * @param stream the address of an {@code ArrowArrayStream} the caller owns + * and has not initialised, written only on success and released through + * its own release callback rather than by anything here + * @param rowsPerBatch how many rows a consumer sees at a time, or zero for + * {@link #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 + * @throws ZuProgrammingException if the stream address is zero, the batch + * is negative, or a column holds something Arrow has no type for + * @throws ZuClosedException if this result is already closed + */ + public void exportArrow(long stream, long rowsPerBatch) { + long h = open(); + if (stream == 0) { + throw new ZuProgrammingException( + Diagnostic.misuse(Status.MISUSE, "the stream to export into is null")); + } + if (rowsPerBatch < 0) { + throw new ZuProgrammingException( + Diagnostic.misuse(Status.MISUSE, "a batch of fewer than no rows: " + rowsPerBatch)); + } + // The handle goes before the call rather than after it. The engine nulls + // the result on every path it takes, refusals included, so a result this + // still thought it owned would be one a later close would free twice. + handle.set(0); + zu.resultArrow(conn == null ? 0 : conn.lend(), h, rowsPerBatch, stream); + } + /** * Whether this result has been closed. * diff --git a/zudb/src/main/java/dev/zudb/Statement.java b/zudb/src/main/java/dev/zudb/Statement.java index 2d47cda..d64628b 100644 --- a/zudb/src/main/java/dev/zudb/Statement.java +++ b/zudb/src/main/java/dev/zudb/Statement.java @@ -37,10 +37,12 @@ public final class Statement implements AutoCloseable { private final ZuBinding zu; private final AtomicLong handle; + private final Connection conn; - Statement(ZuBinding zu, long handle) { + Statement(ZuBinding zu, long handle, Connection conn) { this.zu = zu; this.handle = new AtomicLong(handle); + this.conn = conn; } /** @@ -241,7 +243,7 @@ public Statement bindNull(String name) { * @return the result, which the caller closes */ public Result execute() { - return new Result(zu, zu.execute(open())); + return new Result(zu, zu.execute(open()), conn); } /** diff --git a/zudb/src/main/java/dev/zudb/spi/ZuBinding.java b/zudb/src/main/java/dev/zudb/spi/ZuBinding.java index bb90b60..d08f3df 100644 --- a/zudb/src/main/java/dev/zudb/spi/ZuBinding.java +++ b/zudb/src/main/java/dev/zudb/spi/ZuBinding.java @@ -543,6 +543,32 @@ public interface ZuBinding { */ ByteBuffer chunkValid(long result, long chunk, int col, long rows); + // ---- arrow ---- + + /** + * Hands a whole result to an Arrow consumer through the C Data Interface, + * and spends it. + * + *

Nothing here is a copy. The arrays that cross are the buffers the + * engine's executor filled, at the addresses it filled them at, which is + * why the result is spent: once the buffers have left there is nothing on + * this side to read a second time. The engine writes null back through the + * handle on every path, including a refusal, so an implementation must + * treat the result as gone whatever this call answered and the API module + * must not free it afterwards. + * + * @param conn the connection the result was produced on, or zero. It is + * what a node column's table name is read out of, and a result whose + * connection has closed still exports, naming tables by their ids + * @param result the result, which is gone when this returns + * @param rowsPerBatch how many rows a consumer sees at a time, or zero for + * the engine's own + * @param stream the address of an {@code ArrowArrayStream} the caller owns + * and has not initialised, written only on success and released through + * its own release callback rather than by anything here + */ + void resultArrow(long conn, long result, long rowsPerBatch, long stream); + // ---- values ---- /** From 0caf1bcb8ff869a3d95129f60c9eb3e48abc60d2 Mon Sep 17 00:00:00 2001 From: Tam Nguyen Duc <1218621+tamnd@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:34:00 +0700 Subject: [PATCH 2/2] 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. --- zudb/src/main/java/dev/zudb/Zu.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zudb/src/main/java/dev/zudb/Zu.java b/zudb/src/main/java/dev/zudb/Zu.java index eced10c..13111e3 100644 --- a/zudb/src/main/java/dev/zudb/Zu.java +++ b/zudb/src/main/java/dev/zudb/Zu.java @@ -37,7 +37,7 @@ public final class Zu { * has every symbol this client calls, which is the mismatch that actually * bites, and it names the missing one. */ - public static final String ABI_VERSION = "0.11"; + public static final String ABI_VERSION = "0.12"; private static final Logger LOG = System.getLogger("dev.zudb");