From da00dc2d2a1b8025e2d4f2bc72b04f96f78801bf Mon Sep 17 00:00:00 2001 From: Tam Nguyen Duc <1218621+tamnd@users.noreply.github.com> Date: Thu, 20 Aug 2026 08:59:48 +0700 Subject: [PATCH] Query memory the caller already holds A frame names columns a program is already holding as a table of one connection, and statements read those buffers where they lie. Nothing is copied at registration and nothing is copied at read. Describing a million rows over three columns and registering them costs 0.51 ns a row, against 630 ns a row to put the same rows through a loader, and the scan afterwards is 1.06 ns a row against 1.14 for the same numbers in a database, so the query is not paying for it later. Every buffer has to be direct and one that is not is refused rather than copied. Everywhere else in this client a heap buffer costs a memcpy and nothing else, because the call reads it and is finished. A frame keeps the pointer for as long as it is registered, so a copy here would mean the engine reading a copy for the rest of the frame's life, which is a frame that is not a frame. The direct buffer a caller hands over is looked after for it. A frame holds a reference to everything it was given and lets go at the moment the engine says it has finished, which is after the last statement reading the frame ends and is neither the unregister that preceded it nor the close. Without that a buffer nothing else refers to is freed by a cleaner while the engine is still pointing at it, which is what the float benchmark found before this held on to anything. That moment is an upcall, which is the first thing in this binding that hands the engine a pointer to Java code rather than the other way round. The stub has to outlive the frame, because the callback is the last thing to happen, and it cannot free itself either, since closing the arena a stub lives in from inside a call through that same stub is closing the ground you are standing on. So a spent arena goes on a queue and the next stub to be made closes it. Nothing may be thrown out of an upcall, so the callback logs and swallows. Twenty three tests over every column shape the ABI carries, including a collector run against a frame nothing else refers to, and a benchmark that puts a frame beside the database it is standing in for. --- README.md | 41 +- .../main/java/dev/zudb/bench/FrameBench.java | 195 +++++++ zudb-ffm/src/main/java/dev/zudb/ffm/Abi.java | 80 +++ .../main/java/dev/zudb/ffm/FfmBinding.java | 257 +++++++++ .../src/main/java/dev/zudb/ffm/Release.java | 141 +++++ .../src/test/java/dev/zudb/ffm/FrameTest.java | 488 ++++++++++++++++++ zudb/src/main/java/dev/zudb/Connection.java | 61 +++ zudb/src/main/java/dev/zudb/Frame.java | 405 +++++++++++++++ .../src/main/java/dev/zudb/spi/ZuBinding.java | 143 +++++ 9 files changed, 1810 insertions(+), 1 deletion(-) create mode 100644 zudb-bench/src/main/java/dev/zudb/bench/FrameBench.java create mode 100644 zudb-ffm/src/main/java/dev/zudb/ffm/Release.java create mode 100644 zudb-ffm/src/test/java/dev/zudb/ffm/FrameTest.java create mode 100644 zudb/src/main/java/dev/zudb/Frame.java diff --git a/README.md b/README.md index 3b05be7..fa5af6f 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ A row at a time is a boundary crossing a cell, and a hundred crossings cost abou ## Getting rows in -Two ways, and which one you want follows from whether the database exists yet. +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. A loader builds one out of whole columns. It is the fastest way values get in and, while the engine has no DDL, it is the only way a table comes into being at all: @@ -111,6 +111,45 @@ The first two lines are the copy: 0.68 ns a row over a hundred thousand rows is The last line is the one to read twice. A statement per row parses, plans, runs and commits per row, and none of that work says anything the row before it did not already say. That is what an appender is for. +## Querying memory you already hold + +The third way is not to get the rows in at all. A frame names columns your program is already holding as a table of one connection, and statements read your buffers where they lie: + +```java +LongBuffer ids = ByteBuffer.allocateDirect(3 * 8).order(nativeOrder()).asLongBuffer(); +ids.put(new long[] {1, 2, 3}).flip(); + +try (Frame people = Frame.of("Person", 3)) { + people.column("id", ids); + conn.register(people); + try (Result r = conn.query("MATCH (p:Person) RETURN sum(p.id) AS total")) { + System.out.println(r.row(0).getLong(0)); + } +} +``` + +Nothing is copied at registration and nothing is copied at read. A scan builds vectors pointing straight at your buffers wherever the layouts agree, and the layouts that agree are the ones Arrow and this engine both keep: 64-bit signed integers, doubles, one bit a row for a boolean, and characters end to end with offsets cutting them up. A narrower integer, an unsigned one, a single-precision float and Arrow's microseconds against the nanoseconds this engine keeps time in are widened a value at a time as a statement reaches them, so a frame of a hundred columns costs you the one the statement named. + +Every buffer has to be direct, and one that is not is refused rather than copied. Everywhere else in this API a heap buffer costs a memcpy and nothing else, because the call reads it and is finished. A frame keeps the pointer for as long as it is registered, so a copy here would mean the engine reading a copy of your data for the rest of the frame's life, which is a frame that is not a frame. + +The buffers stay yours and the library never writes one, but a direct buffer is looked after for you: the frame holds a reference to everything handed to it and lets go at the moment the engine says it has finished, which is after the last statement reading the frame ends and is neither the unregister that preceded it nor the close. So a buffer cannot be collected out from under a running statement. The optional release callback runs at that same moment, and it is there for the other kind of buffer, one over memory you allocated yourself or one a lock has to be taken to let go of. + +A frame is read only and has no edges. A statement that would write to one is refused, a name a stored table already holds is refused, a name another frame holds replaces that frame, and registering inside a transaction is refused because a table appearing halfway through one is not something the transaction could then be rolled back over. + +A million rows, three columns, the same laptop: + +| How | Per row | +|---|---| +| describing them and registering them | 0.51 ns | +| the same million rows through a loader, write included | 630 ns | +| `sum(p.id)` over a frame | 1.06 ns | +| `sum(p.id)` over the same numbers in a database | 1.14 ns | +| the same over a 32-bit column, which is widened as it is read | 1.32 ns | +| a string comparison over a frame | 2.26 ns | +| the same over a database | 3.89 ns | + +The first two lines are the whole point. Half a millisecond to make a million rows queryable against six hundred to write them down, and the three lines after that say the query is not paying for it afterwards. + ## How it binds The Foreign Function and Memory API is the primary path. The downcall handles are written by hand against `zu.h` rather than generated with `jextract`, because the C ABI here is around seventy functions with a stable shape, and a hand-written layer is where the interesting decisions live: which calls are `Linker.Option.critical` because they are short pure accessors, where the out-parameter scratch space comes from so that a query does not allocate, and how a `zu_error` becomes a typed Java exception exactly once. There is no native code in this repository beyond `libzu` itself. diff --git a/zudb-bench/src/main/java/dev/zudb/bench/FrameBench.java b/zudb-bench/src/main/java/dev/zudb/bench/FrameBench.java new file mode 100644 index 0000000..3b16bec --- /dev/null +++ b/zudb-bench/src/main/java/dev/zudb/bench/FrameBench.java @@ -0,0 +1,195 @@ +package dev.zudb.bench; + +import dev.zudb.Connection; +import dev.zudb.Database; +import dev.zudb.Frame; +import dev.zudb.Loader; +import dev.zudb.Result; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.IntBuffer; +import java.nio.LongBuffer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +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 it costs to query a million rows the program is already holding, + * against what the same million rows cost once they are in a database. + * + *
Two things are being measured here and they answer different questions.
+ * {@code register} is how long it takes to make a million rows queryable,
+ * which is the number to read against a load: a load writes a file and a frame
+ * writes nothing. The scans are what a statement costs afterwards, and the
+ * point of those is that a frame is not a slower way of reading, it is the
+ * same read against somebody else's memory.
+ */
+@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"})
+public class FrameBench {
+
+ /** How many rows both sides carry. */
+ private static final int ROWS = 1_000_000;
+
+ private Path dir;
+
+ private LongBuffer ids;
+ private IntBuffer narrow;
+ private IntBuffer offsets;
+ private ByteBuffer characters;
+
+ private Database memory;
+ private Connection frames;
+ private Connection spare;
+ private Frame frame;
+
+ private Database stored;
+ private Connection disk;
+
+ @Setup
+ public void fill() throws IOException {
+ dir = Files.createTempDirectory("zu-frame-bench");
+
+ ids = direct(ROWS * Long.BYTES).asLongBuffer();
+ narrow = direct(ROWS * Integer.BYTES).asIntBuffer();
+ List This is the one place a copy is refused instead of made. Everywhere
+ * else a heap buffer costs a memcpy and nothing else, because the call reads
+ * it and is done. A frame keeps the pointer for as long as it is registered,
+ * and a heap buffer has no address anything outside the JVM can keep, so a
+ * copy here would mean the engine reading a copy for the rest of the frame's
+ * life. That is a frame that is not a frame, and quietly making one is worse
+ * than saying so.
+ */
+ private static MemorySegment lent(java.nio.Buffer buffer, String name) {
+ if (buffer == null) {
+ throw Diagnostic.misuse(Status.MISUSE, "column " + name + " of a frame has no buffer")
+ .toException();
+ }
+ if (!buffer.isDirect()) {
+ throw Diagnostic.misuse(
+ Status.MISUSE,
+ "column "
+ + name
+ + " of a frame is on the heap, and a frame is read where it lies rather than"
+ + " copied, so it wants a buffer from ByteBuffer.allocateDirect")
+ .toException();
+ }
+ return MemorySegment.ofBuffer(buffer);
+ }
+
/** One of the {@code (handle, uint64_t *out)} calls that cannot fail with an error. */
private long counter(java.lang.invoke.MethodHandle mh, String what, long handle) {
Scratch s = Scratch.get();
diff --git a/zudb-ffm/src/main/java/dev/zudb/ffm/Release.java b/zudb-ffm/src/main/java/dev/zudb/ffm/Release.java
new file mode 100644
index 0000000..8b7ba03
--- /dev/null
+++ b/zudb-ffm/src/main/java/dev/zudb/ffm/Release.java
@@ -0,0 +1,141 @@
+package dev.zudb.ffm;
+
+import static java.lang.foreign.ValueLayout.ADDRESS;
+
+import java.lang.System.Logger;
+import java.lang.System.Logger.Level;
+import java.lang.foreign.Arena;
+import java.lang.foreign.FunctionDescriptor;
+import java.lang.foreign.Linker;
+import java.lang.foreign.MemorySegment;
+import java.lang.invoke.MethodHandle;
+import java.lang.invoke.MethodHandles;
+import java.lang.invoke.MethodType;
+import java.util.Queue;
+import java.util.concurrent.ConcurrentLinkedQueue;
+
+/**
+ * The one place this binding hands the engine a pointer to Java code rather
+ * than the other way round.
+ *
+ * A frame's release callback is how a host learns the engine has finished
+ * with the buffers it lent: it runs once, on a thread of the library's, after
+ * the last statement reading the frame ends. Reaching Java from there needs an
+ * upcall stub, which is executable memory with a lifetime of its own.
+ *
+ * That lifetime is the awkward part. The stub has to outlive the frame,
+ * because the callback is the last thing to happen and may happen after the
+ * frame was freed, so it cannot hang off the frame. It cannot free itself
+ * either: closing the arena a stub lives in from inside a call through that
+ * same stub is closing the ground you are standing on. So a spent arena goes
+ * on a queue and the next stub to be made closes it, which costs nothing, needs
+ * no thread of ours, and bounds the outstanding stubs at the number of frames
+ * whose callbacks have not run yet.
+ */
+final class Release {
+
+ private static final Logger LOG = System.getLogger("dev.zudb");
+
+ private static final FunctionDescriptor DESCRIPTOR = FunctionDescriptor.ofVoid(ADDRESS);
+
+ private static final MethodHandle RUN = run();
+
+ /** Arenas whose callback has been and gone, waiting for somebody else to close them. */
+ private static final Queue Nothing may be thrown out of here. An exception crossing an upcall
+ * takes the whole JVM down, and a host's release callback is exactly the
+ * kind of code that throws: it takes a lock, or a runtime's interpreter
+ * lock, and lets go of buffers. So it is logged and swallowed, which leaves
+ * the process alive and the mistake findable.
+ *
+ * @param owner the pointer passed at creation, which this binding does not
+ * use because the runnable already knows what it owns
+ */
+ @SuppressWarnings("unused")
+ private void run(MemorySegment owner) {
+ try {
+ body.run();
+ } catch (Throwable t) {
+ LOG.log(Level.ERROR, "a zu frame release callback threw, which nothing above it can see", t);
+ } finally {
+ SPENT.add(arena);
+ }
+ }
+
+ /** Closes the arenas of every callback that has already run. */
+ private static void sweep() {
+ for (Arena arena = SPENT.poll(); arena != null; arena = SPENT.poll()) {
+ try {
+ arena.close();
+ } catch (RuntimeException e) {
+ // A shared arena refuses to close while a thread is still inside a
+ // call into it. Put it back and let the next frame try.
+ SPENT.add(arena);
+ return;
+ }
+ }
+ }
+
+ private static MethodHandle run() {
+ try {
+ return MethodHandles.lookup()
+ .findVirtual(Release.class, "run", MethodType.methodType(void.class, MemorySegment.class));
+ } catch (ReflectiveOperationException e) {
+ throw new ExceptionInInitializerError(e);
+ }
+ }
+}
diff --git a/zudb-ffm/src/test/java/dev/zudb/ffm/FrameTest.java b/zudb-ffm/src/test/java/dev/zudb/ffm/FrameTest.java
new file mode 100644
index 0000000..958cc18
--- /dev/null
+++ b/zudb-ffm/src/test/java/dev/zudb/ffm/FrameTest.java
@@ -0,0 +1,488 @@
+package dev.zudb.ffm;
+
+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.Frame;
+import dev.zudb.Loader;
+import dev.zudb.Result;
+import dev.zudb.Value;
+import dev.zudb.ZuException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.DoubleBuffer;
+import java.nio.FloatBuffer;
+import java.nio.IntBuffer;
+import java.nio.LongBuffer;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * Querying memory the test already holds, without any of it getting into a
+ * database.
+ */
+class FrameTest {
+
+ @TempDir Path dir;
+
+ @BeforeAll
+ static void engine() {
+ Libzu.require();
+ }
+
+ @Test
+ void aColumnOfLongsIsQueriedWhereItLies() {
+ try (Database db = Database.memory();
+ Connection conn = db.connect();
+ Frame frame = Frame.of("Person", 3)) {
+ frame.column("id", longs(1, 2, 3));
+ conn.register(frame);
+ try (Result r = conn.query("MATCH (p:Person) RETURN p.id AS id ORDER BY id")) {
+ assertEquals(List.of(1L, 2L, 3L), r.stream().map(row -> row.getLong(0)).toList());
+ }
+ }
+ }
+
+ @Test
+ void everyWidthAndSignComesBackAsTheNumberItIs() {
+ try (Database db = Database.memory();
+ Connection conn = db.connect();
+ Frame frame = Frame.of("Wide", 2)) {
+ frame.column("big", longs(1, -2));
+ frame.column("mid", ints(3, -4));
+ frame.column("small", shorts((short) 5, (short) -6));
+ frame.integers("tiny", bytes((byte) 7, (byte) 8), 2, 8, false, 1, null);
+ frame.integers("unsigned", ints(9, 10), 2, 32, false, 1, null);
+ conn.register(frame);
+ try (Result r =
+ conn.query("MATCH (w:Wide) RETURN w.big, w.mid, w.small, w.tiny, w.unsigned")) {
+ assertEquals(1L, r.row(0).getLong(0));
+ assertEquals(3L, r.row(0).getLong(1));
+ assertEquals(5L, r.row(0).getLong(2));
+ assertEquals(7L, r.row(0).getLong(3));
+ assertEquals(9L, r.row(0).getLong(4));
+ assertEquals(-2L, r.row(1).getLong(0));
+ assertEquals(-4L, r.row(1).getLong(1));
+ assertEquals(-6L, r.row(1).getLong(2));
+ assertEquals(8L, r.row(1).getLong(3));
+ assertEquals(10L, r.row(1).getLong(4));
+ }
+ }
+ }
+
+ @Test
+ void bothWidthsOfFloatComeBack() {
+ try (Database db = Database.memory();
+ Connection conn = db.connect();
+ Frame frame = Frame.of("Reading", 2)) {
+ frame.column("wide", doubles(1.5, 2.5));
+ frame.column("narrow", floats(0.5f, 0.25f));
+ conn.register(frame);
+ try (Result r = conn.query("MATCH (x:Reading) RETURN x.wide, x.narrow")) {
+ assertEquals(1.5, r.row(0).getDouble(0));
+ assertEquals(0.5, r.row(0).getDouble(1));
+ assertEquals(2.5, r.row(1).getDouble(0));
+ assertEquals(0.25, r.row(1).getDouble(1));
+ }
+ }
+ }
+
+ @Test
+ void aBitmapIsAColumnOfBooleans() {
+ // 0b00001101: true, false, true, true.
+ ByteBuffer bitmap = ByteBuffer.allocateDirect(1);
+ bitmap.put((byte) 0b1101).flip();
+ try (Database db = Database.memory();
+ Connection conn = db.connect();
+ Frame frame = Frame.of("Flag", 4)) {
+ frame.booleans("on", bitmap, 4);
+ conn.register(frame);
+ try (Result r = conn.query("MATCH (f:Flag) RETURN f.on")) {
+ assertEquals(
+ List.of(true, false, true, true),
+ r.stream().map(row -> row.getBoolean(0)).toList());
+ }
+ }
+ }
+
+ @Test
+ void charactersEndToEndAreAColumnOfStrings() {
+ List Nothing is copied here or at read. The frame is not spent either, so
+ * the same columns may be registered on as many connections as there are
+ * threads to query them from.
+ *
+ * This is where everything a frame's description could get wrong is
+ * settled: alignment, an unsigned value too large for the signed lane, a
+ * scale that would overflow, an offset that leaves its buffer. After it
+ * returns, a read of the frame cannot fail.
+ *
+ * @param frame the columns and what they are called
+ * @throws ZuException if a stored table already holds the name, or if a
+ * transaction is running, since a table appearing halfway through one is
+ * not something the transaction could be rolled back over
+ */
+ public void register(Frame frame) {
+ zu.connRegister(open(), frame.handle());
+ }
+
+ /**
+ * Drops a registered frame.
+ *
+ * A statement already running keeps the frame it started with, and the
+ * release callback waits for it.
+ *
+ * @param name the table name it was registered under
+ * @return whether there was one under that name
+ */
+ public boolean unregister(String name) {
+ return zu.connUnregister(open(), name);
+ }
+
+ /**
+ * How many frames are registered.
+ *
+ * @return the count
+ */
+ public long registeredCount() {
+ return zu.connRegisteredCount(open());
+ }
+
+ /**
+ * What every registered frame is called, sorted.
+ *
+ * @return the names, which is a list of its own and not a view of anything
+ */
+ public List This is the other direction from {@link Loader}. A loader takes your
+ * columns and writes a database out of them. A frame writes nothing at all:
+ * you describe the memory you are already holding, register it on a
+ * {@link Connection}, and statements read your buffers directly. Nothing is
+ * copied at registration and nothing is copied at read, so a frame of ten
+ * million rows is ready in the time it takes to walk its columns.
+ *
+ * Every buffer has to be direct, and one that is not is refused rather than
+ * copied. Everywhere else in this API a heap buffer costs a memcpy and nothing
+ * else, because the call reads it and is finished. A frame keeps the pointer
+ * for as long as it is registered, so a copy here would mean the engine
+ * reading a copy of your data for the rest of the frame's life. That is a
+ * frame that is not a frame, and it is better said out loud than done quietly.
+ *
+ * The buffers stay yours and this library never writes one. What it asks is
+ * that each stays where it is, unwritten and unfreed, until the engine is
+ * finished with it, which is after the last statement reading the frame ends
+ * and is neither the unregister that preceded it nor {@link #close()}.
+ *
+ * For a direct buffer that is looked after for you. A frame keeps a
+ * reference to everything handed to it and lets go only when the engine says
+ * it is finished, so a buffer cannot be collected out from under a running
+ * statement and you do not have to keep a field alive to prevent it. The
+ * optional release callback is for the other kind of buffer: one over memory
+ * you allocated yourself, or one a lock has to be taken to let go of. It runs
+ * once, on a thread of the library's, at that same moment.
+ *
+ * The order is fixed: make the frame, describe one column at a time, then
+ * register it. A column whose count does not match the frame's row count is
+ * refused at that column, where you still know which one you were describing.
+ * Everything else that can go wrong is settled at
+ * {@link Connection#register(Frame)}: alignment, an unsigned value too large
+ * for the signed lane, a scale that would overflow, an offset that leaves its
+ * buffer. A read of a registered frame cannot fail, which is what lets a scan
+ * be a loop.
+ *
+ * A frame is described once and registered as often as you like, on as many
+ * connections as you like. It is read only and has no edges: a statement that
+ * would insert into, set on or delete from a registered name is refused, a
+ * name a stored table already holds is refused, and a name another frame holds
+ * replaces that frame.
+ *
+ * Which lane a column takes decides what it costs to read. Sixty-four
+ * signed bits, doubles, one bit a row for a boolean, and characters end to end
+ * with offsets cutting them up are what this engine keeps natively, and those
+ * cost nothing. A narrower integer, an unsigned one, a single-precision float
+ * and Arrow's microseconds against the nanoseconds this engine keeps time in
+ * are widened a value at a time as a statement reaches them, so they cost
+ * something, but only for the columns the statement actually named.
+ */
+public final class Frame implements AutoCloseable {
+
+ /** What the C ABI calls a column of numbers and nothing else. */
+ private static final int PLAIN = -1;
+
+ private final ZuBinding zu;
+ private final AtomicLong handle;
+ private final String name;
+ private final long rows;
+ private final List This is the full form the six calls above are shorthand for, and it is
+ * what an Arrow array of any integer type maps onto.
+ *
+ * @param column the column
+ * @param values the buffer, which has to be direct
+ * @param count how many values are in it, which has to be the frame's row
+ * count
+ * @param bits 8, 16, 32 or 64
+ * @param signed whether they are signed
+ * @param scale what one value is multiplied by to reach the unit its
+ * meaning counts in, so 1 for an integer and a date, and 1000 for the
+ * microseconds Arrow keeps a time or a timestamp in
+ * @param kind what the counts mean, or null for a column of numbers and
+ * nothing else
+ * @return this frame
+ */
+ public Frame integers(
+ String column,
+ Buffer values,
+ long count,
+ int bits,
+ boolean signed,
+ long scale,
+ Value.Temporal.Kind kind) {
+ zu.frameColumnInts(
+ open(), column, values, count, bits, signed, scale, kind == null ? PLAIN : kind.value());
+ return keep(values);
+ }
+
+ /**
+ * A column of booleans, one bit a row, low bit of the first byte first,
+ * which is Arrow's bitmap and this engine's alike.
+ *
+ * A host holding a slice with a bit offset of its own owes the shift
+ * before it gets here: a bitmap that starts partway into a byte is not a
+ * thing a pointer can say.
+ *
+ * @param column the column
+ * @param bitmap the bits, which has to be direct
+ * @param count how many rows are in it, which the bitmap cannot say
+ * @return this frame
+ */
+ public Frame booleans(String column, ByteBuffer bitmap, long count) {
+ zu.frameColumnBooleans(open(), column, bitmap, count);
+ return keep(bitmap);
+ }
+
+ /**
+ * A column of strings as Arrow's Utf8 keeps them.
+ *
+ * The characters never move, either now or at read: what a scan builds
+ * is a view pointing back into your data buffer.
+ *
+ * @param column the column
+ * @param offsets where each string starts, of which there are one more than
+ * there are rows, the last being how much of data is used
+ * @param data the characters, end to end
+ * @return this frame
+ */
+ public Frame strings(String column, IntBuffer offsets, ByteBuffer data) {
+ zu.frameColumnStrings(open(), column, offsets, false, data, offsets.remaining() - 1L);
+ return keep(offsets).keep(data);
+ }
+
+ /**
+ * A column of strings as Arrow's LargeUtf8 keeps them, which is the same
+ * with 64-bit offsets.
+ *
+ * @param column the column
+ * @param offsets where each string starts, of which there are one more than
+ * there are rows
+ * @param data the characters, end to end
+ * @return this frame
+ */
+ public Frame strings(String column, LongBuffer offsets, ByteBuffer data) {
+ zu.frameColumnStrings(open(), column, offsets, true, data, offsets.remaining() - 1L);
+ return keep(offsets).keep(data);
+ }
+
+ /**
+ * A column of strings as Arrow's Utf8View keeps them, sixteen bytes a row
+ * over one or more data buffers.
+ *
+ * A short string in that layout is already this engine's own view, byte
+ * for byte, so this is the cheapest string column there is.
+ *
+ * @param column the column
+ * @param views sixteen bytes a row
+ * @param data the buffers they point into
+ * @return this frame
+ */
+ public Frame views(String column, ByteBuffer views, List This is not what tells you the engine has let go of your buffers. A
+ * statement that started before this may still be reading them, and the
+ * release callback is what runs when the last one has finished. Closing
+ * twice does nothing the second time.
+ */
+ @Override
+ public void close() {
+ long h = handle.getAndSet(0);
+ if (h != 0) {
+ zu.frameFree(h);
+ }
+ }
+
+ long handle() {
+ return open();
+ }
+
+ /**
+ * Holds on to a buffer the engine now points at, so that a collector cannot
+ * free it while a statement is reading it.
+ */
+ private Frame keep(Buffer buffer) {
+ lent.add(buffer);
+ return this;
+ }
+
+ private long open() {
+ long h = handle.get();
+ if (h == 0) {
+ throw new ZuClosedException(Diagnostic.misuse(Status.MISUSE_CLOSED, "this frame is closed"));
+ }
+ return h;
+ }
+}
diff --git a/zudb/src/main/java/dev/zudb/spi/ZuBinding.java b/zudb/src/main/java/dev/zudb/spi/ZuBinding.java
index ba8429e..7db49f3 100644
--- a/zudb/src/main/java/dev/zudb/spi/ZuBinding.java
+++ b/zudb/src/main/java/dev/zudb/spi/ZuBinding.java
@@ -1,6 +1,7 @@
package dev.zudb.spi;
import dev.zudb.Diagnostic;
+import java.nio.Buffer;
import java.nio.ByteBuffer;
import java.nio.DoubleBuffer;
import java.nio.IntBuffer;
@@ -811,4 +812,146 @@ public interface ZuBinding {
* @param appender the appender
*/
void appenderFree(long appender);
+
+ // ---- frames ----
+
+ /**
+ * Describes a table of columns the host already holds, which the engine
+ * will read where they lie.
+ *
+ * Every buffer handed to a {@code frameColumn} call below has to be a
+ * direct one. A frame keeps the pointer rather than the values, and a heap
+ * buffer has no address anything outside the JVM can keep. A provider
+ * refuses one rather than copying, because a copy would quietly undo the
+ * only thing a frame is for.
+ *
+ * @param name what the table is called in a statement
+ * @param rows how many rows every column of it carries
+ * @param release run once, on a thread of the library's, after the last
+ * statement reading this frame ends, or null for a host whose buffers
+ * outlive the process
+ * @return the handle
+ */
+ long frameNew(String name, long rows, Runnable release);
+
+ /**
+ * A column of integers, or of a temporal counted as integers.
+ *
+ * @param frame the frame
+ * @param name the column
+ * @param values the buffer, which has to be direct
+ * @param count how many values are in it
+ * @param bits 8, 16, 32 or 64, where 64 signed at scale 1 is the lane this
+ * engine reads natively and the column that costs nothing at all
+ * @param signed whether they are signed
+ * @param scale what one value is multiplied by to reach the unit its
+ * meaning counts in, so 1 for an integer and a date and 1000 for the
+ * microseconds Arrow keeps a timestamp in
+ * @param temporal one of the {@code ZU_TEMPORAL_} kinds, or -1 for a column
+ * of numbers and nothing else
+ */
+ void frameColumnInts(
+ long frame,
+ String name,
+ Buffer values,
+ long count,
+ int bits,
+ boolean signed,
+ long scale,
+ int temporal);
+
+ /**
+ * A column of floating point numbers.
+ *
+ * @param frame the frame
+ * @param name the column
+ * @param values the buffer, which has to be direct
+ * @param count how many values are in it
+ * @param bits 32 or 64, where 64 is the lane
+ */
+ void frameColumnFloats(long frame, String name, Buffer values, long count, int bits);
+
+ /**
+ * A column of booleans, one bit a row, low bit of the first byte first,
+ * which is Arrow's bitmap and this engine's alike.
+ *
+ * @param frame the frame
+ * @param name the column
+ * @param bitmap the buffer, which has to be direct
+ * @param count how many rows are in it, which the bitmap cannot say
+ */
+ void frameColumnBooleans(long frame, String name, Buffer bitmap, long count);
+
+ /**
+ * A column of strings as Arrow keeps them, characters end to end with
+ * offsets cutting them up.
+ *
+ * @param frame the frame
+ * @param name the column
+ * @param offsets the offsets, which has to be direct, and of which there
+ * are count + 1
+ * @param wide false for 32-bit offsets, which is Arrow's Utf8, and true for
+ * 64-bit, which is its LargeUtf8
+ * @param data the characters, which has to be direct
+ * @param count how many rows are in it
+ */
+ void frameColumnStrings(
+ long frame, String name, Buffer offsets, boolean wide, Buffer data, long count);
+
+ /**
+ * A column of strings as Arrow's Utf8View keeps them, sixteen bytes a row
+ * over one or more data buffers.
+ *
+ * @param frame the frame
+ * @param name the column
+ * @param views the views, which has to be direct
+ * @param data the buffers they point into, each of which has to be direct
+ * @param count how many rows are in it
+ */
+ void frameColumnViews(long frame, String name, Buffer views, List{@code
+ * LongBuffer ids = ByteBuffer.allocateDirect(3 * 8).order(ByteOrder.nativeOrder()).asLongBuffer();
+ * ids.put(new long[] {1, 2, 3}).flip();
+ * try (Frame frame = Frame.of("Person", 3)) {
+ * frame.column("id", ids);
+ * conn.register(frame);
+ * try (Result r = conn.query("MATCH (p:Person) RETURN sum(p.id) AS total")) {
+ * System.out.println(r.row(0).getLong(0));
+ * }
+ * }
+ * }
+ *
+ *