Java SDK v1: the API, the Panama provider, tests and benchmarks - #1

Merged
tamnd merged 1 commit into
mainfrom
java-core
Aug 20, 2026
Merged

Java SDK v1: the API, the Panama provider, tests and benchmarks#1
tamnd merged 1 commit into
mainfrom
java-core

Conversation

@tamnd

Copy link
Copy Markdown
Owner

This is the first code in the repository. Two artifacts, one of which is what a caller compiles against and the other of which is the only thing that calls libzu.

dev.zudb:zudb is the API. Release 17, no native code, no FFM type anywhere in its public surface, so it is the artifact a caller on any supported JDK depends on. dev.zudb:zudb-ffm is the Panama provider, release 25. A ServiceLoader picks between providers at run time and application code never names one.

Why the bindings are hand written

The seed README said jextract would generate them. It does not, and it should not. The ABI here is around seventy functions with a shape that does not move, and the decisions worth making are the ones a generator does not make.

Which calls get Linker.Option.critical(false), for one. zu_result_rows, zu_value_type, zu_error_status and six others are pure accessors that read a field and return, and paying a thread state transition for each of them is most of what they cost.

Where the out-parameter space comes from, for another. Every one of these calls needs somewhere to put a pointer or a length, and an Arena.ofConfined() per call is a malloc and a free on a path that is otherwise a handful of instructions. Scratch is a per-thread off-heap block that a call resets with a bump pointer at the top and writes into. No binding method calls another and every out-parameter is read before the call returns, so there is nothing a reset can invalidate.

And how a zu_error becomes a Java exception, which happens in exactly one place. The subclass comes from the GQLSTATUS class rather than from the message: 42 is a ZuSyntaxException, 22 is a ZuDataException, 25 and 40 are ZuTransactionException, and so on down. The exception carries the whole diagnostic, so a caller reads code(), condition(), position() and retryable() rather than parsing prose. The error is freed in a finally, and there is a test that provokes a thousand failures and then keeps using the connection.

Two things about the ABI that are worth knowing

zu_config is filled in by this client rather than by zu_config_init. The struct is versioned by a struct_size the caller sets, and asking a newer library to initialise a buffer sized by our header is asking it to write past the end of it.

ZU_ABI_VERSION is a header macro rather than a symbol, so a binding with no C compile step has nowhere to read it from and has to write it down. Zu.ABI_VERSION is that copy, and there is a CI step that checks it against the engine's zu.h so it cannot drift. A library that is too old to have a function this client calls is caught at load, by name, rather than at the call.

The columnar surface

Every column of a result is readable as one borrowed buffer over the engine's own memory. java.nio rather than MemorySegment, because a Java 17 caller can name a LongBuffer and the JNI provider can hand back the same thing without copying. Read-only, and in native byte order set explicitly, because asByteBuffer() returns a big-endian view and a wrong byte order is a wrong number rather than a failure.

Summing one integer column of a hundred thousand rows, M-series laptop, JDK 25:

HowPer row
r.longs(0) and a loop over the buffer0.45 ns
the same a chunk at a time4.1 ns
for (Row row : r) row.getLong(0)45 ns
r.stream().mapToLong(...)67 ns

A row at a time is a boundary crossing a cell, and a hundred of those cost about what one borrowed buffer costs. Both surfaces are here because both are the right answer to a different question.

Native access

From JDK 24 a downcall out of a module that was not granted native access warns, and the warning is on a path to becoming an error. The jar carries Enable-Native-Access: ALL-UNNAMED for the class path case, the module path case passes --enable-native-access=dev.zudb.ffm, and FfmProvider checks Module::isNativeAccessEnabled before the first downcall so that a caller who has neither gets an exception naming the flag rather than a JVM warning on stderr three frames from any of our code.

Why 25 and not 22

FFM was finalised in 22 and 22 has been out of support since September 2024. Targeting an unsupported release only moves the problem, so the provider is release 25 and the JNI provider will carry 17 through 21.

Tests

111, green against libzu built from the engine at its own HEAD. The API module tests need no library at all. The FFM tests find one through -Dzu.library, ZU_LIBRARY or a sibling engine checkout, and skip rather than fail when there is none, so a checkout with no engine beside it is still green.

The engine has no DDL, so nothing here writes a schema and the tests live on the expression and projection surface: RETURN, UNWIND, parameters, lists, records, and all seven temporal kinds out and back. range() does not exist either, so the bulk-row tests build a literal list.

CI builds the API artifact on 17, 21, 25 and 26, runs the whole suite on Linux and macOS on 25 and 26, once plainly and once with -ea -esa so that the bounds checks a MemorySegment does are actually on, and builds and runs the benchmarks because a benchmark is code nothing else compiles.

What changed in the README

The opening example used CREATE NODE TABLE and conn.loadCsv. The engine has no DDL and the ABI has no CSV call, so neither would run. The example is now a MATCH over a graph something else built, with a paragraph saying plainly what does and does not work today. The jextract claim is gone for the reason above.

Follow-ups, not in this PR

zu_version in the engine returns a hard-coded 0.0.1 rather than the crate version, so it will drift the first time the crate version moves. Worth a one-line fix in tamnd/zu.

A zu_abi_version() runtime symbol would let a binding with no C compile step ask instead of write the constant down.

Next here: the JNI provider for 17 through 21, the native artifacts, GraalVM reachability metadata, and Maven Central publishing.

Milestone: DX4, tamnd/zu#170.

Two artifacts. dev.zudb:zudb is the API, compiled to release 17, with no
native code in it and no FFM type anywhere in its public surface, so it is
the thing a caller on any supported JDK compiles against. dev.zudb:zudb-ffm
is the provider, compiled to release 25, and it is the only place that
calls libzu. A ServiceLoader picks between providers at run time and
application code never names one.
The downcall handles are written by hand against zu.h rather than generated
with jextract. The ABI here is around seventy functions with a stable shape,
and the decisions worth making are the ones a generator does not make: which
calls are Linker.Option.critical because they are short pure accessors, where
the out-parameter space comes from so that a query does not allocate, and how
a zu_error becomes a typed Java exception exactly once.
Three things in the binding are worth reading before the rest:
Scratch is a per-thread off-heap block that every call writes its
out-parameters into, reset with a bump pointer at the top of each call rather
than allocated per call. An Arena.ofConfined per call is a malloc and a free
on a path that is otherwise a handful of instructions, and no binding method
calls another, so there is nothing for a reset to invalidate.
zu_config is filled in by this client rather than by zu_config_init. The
struct is versioned by a struct_size the caller sets, and asking a newer
library to initialise a buffer sized by our header is asking it to write past
the end of it.
A column comes back as a read-only java.nio buffer over the engine's own
memory, in native byte order, because asByteBuffer hands back a big-endian
view and a wrong byte order is a wrong number rather than a failure. java.nio
rather than MemorySegment so that a Java 17 caller can name the type and so
that the JNI provider can return the same thing.
Native access is granted rather than assumed. The jar carries
Enable-Native-Access: ALL-UNNAMED for the class path case, the module path
case passes --enable-native-access=dev.zudb.ffm, and the provider checks
Module::isNativeAccessEnabled before the first downcall so that a caller who
has neither gets an exception naming the flag instead of a JVM warning three
frames from any of our code.
The FFM artifact targets 25 rather than the 22 that finalised the API,
because 22 has been out of support since September 2024.
111 tests, green against libzu built from the engine at HEAD. The suite skips
rather than fails when there is no library to find, so a checkout with no
engine beside it is still green.
The benchmarks say what the columnar surface is for. Summing one integer
column of a hundred thousand rows costs 0.45 ns a row through r.longs(0),
4.1 ns a row a chunk at a time, 45 ns a row through the Row iterator and
67 ns a row through the Stream. A row at a time is a boundary crossing a
cell, and a hundred of those cost about what one borrowed buffer costs.
CI builds the API artifact on 17, 21, 25 and 26, and runs the whole suite
against the engine at its own HEAD on Linux and macOS, once plainly and once
with assertions on everywhere. One step checks that the ABI version written
down in Zu.ABI_VERSION is the one the engine's zu.h declares, because
ZU_ABI_VERSION is a header macro rather than a symbol and a binding with no C
compile step has nowhere to read it from.
The README no longer opens with a CREATE NODE TABLE the engine cannot run.
@tamnd
tamnd merged commit 0dd94bc into mainAug 20, 2026
9 checks passed
@tamnd
tamnd deleted the java-core branch August 20, 2026 01:06
tamnd added a commit that referenced this pull request Aug 22, 2026
A binding holds native memory and the process that finds out later is
the user's. The suite here cannot see that: a test that closes nothing
and asserts on a message passes, and what it left behind is somebody
else's problem an hour into a run.
So the allocator is asked instead. A driver in the tck opens and closes
every handle this client hands out, failures beside successes, and
scripts/leaks.sh runs it with LeakSanitizer ahead of the JVM and reads
the report for blocks the engine allocated and nobody gave back.
The narrow question is the whole trick. A JVM does not free at exit, on
purpose, so pointing a leak checker at one that does nothing at all
reports about a megabyte in several thousand allocations and none of it
is anything a caller can act on. What is answerable is whether any
unfreed block came out of libzu, and a leak record carries the stack it
was allocated from, so it is answerable by reading frame #1.
Frame #1 rather than any frame, because of what the JNI row turned up.
Asking for a jmethodID allocates a JVM-side table entry the JVM never
frees, and the stack for it runs through the shim because the shim is
what asked. Any-frame matching called seven of those ours and they are
not: at #1 they are os::malloc in libjvm. A block the shim really did
allocate has the shim at #1 and is still caught. The count of records
let through is printed rather than dropped quietly.
The gate runs first and has to fail. A report with no libzu in it looks
the same whether nothing leaked, the sanitizer was never loaded, the
library was never called, or the driver died early, and three of those
four are green for the wrong reason. So the driver is run once with
ZU_LEAK_GATE=1, which drops a database, a connection, a statement, a
result, an appender and a frame on the floor, and the script stops if
that comes back clean.
On server3, both providers: the gate leaks 57 records naming
zu_execute, zu_appender_open and zu_database_open, and the clean run is
0 of 545 on Panama and 0 of 390 on JNI, against a JVM whose own report
is a megabyte either way. The full reactor is green beside it, 199 tck
cases and 12 Arrow.
Linux only. LeakSanitizer does not exist on macOS, and what covers the
same ground there is the lifecycle half of the misuse suite, which
counts open file descriptors either side of a few hundred failures and
needs no allocator to agree with it.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@tamnd
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Java SDK v1: the API, the Panama provider, tests and benchmarks - #1

Merged
tamnd merged 1 commit into
mainfrom
java-core
Aug 20, 2026
Merged

Java SDK v1: the API, the Panama provider, tests and benchmarks#1
tamnd merged 1 commit into
mainfrom
java-core

Conversation

@tamnd

Copy link
Copy Markdown
Owner

This is the first code in the repository. Two artifacts, one of which is what a caller compiles against and the other of which is the only thing that calls libzu.

dev.zudb:zudb is the API. Release 17, no native code, no FFM type anywhere in its public surface, so it is the artifact a caller on any supported JDK depends on. dev.zudb:zudb-ffm is the Panama provider, release 25. A ServiceLoader picks between providers at run time and application code never names one.

Why the bindings are hand written

The seed README said jextract would generate them. It does not, and it should not. The ABI here is around seventy functions with a shape that does not move, and the decisions worth making are the ones a generator does not make.

Which calls get Linker.Option.critical(false), for one. zu_result_rows, zu_value_type, zu_error_status and six others are pure accessors that read a field and return, and paying a thread state transition for each of them is most of what they cost.

Where the out-parameter space comes from, for another. Every one of these calls needs somewhere to put a pointer or a length, and an Arena.ofConfined() per call is a malloc and a free on a path that is otherwise a handful of instructions. Scratch is a per-thread off-heap block that a call resets with a bump pointer at the top and writes into. No binding method calls another and every out-parameter is read before the call returns, so there is nothing a reset can invalidate.

And how a zu_error becomes a Java exception, which happens in exactly one place. The subclass comes from the GQLSTATUS class rather than from the message: 42 is a ZuSyntaxException, 22 is a ZuDataException, 25 and 40 are ZuTransactionException, and so on down. The exception carries the whole diagnostic, so a caller reads code(), condition(), position() and retryable() rather than parsing prose. The error is freed in a finally, and there is a test that provokes a thousand failures and then keeps using the connection.

Two things about the ABI that are worth knowing

zu_config is filled in by this client rather than by zu_config_init. The struct is versioned by a struct_size the caller sets, and asking a newer library to initialise a buffer sized by our header is asking it to write past the end of it.

ZU_ABI_VERSION is a header macro rather than a symbol, so a binding with no C compile step has nowhere to read it from and has to write it down. Zu.ABI_VERSION is that copy, and there is a CI step that checks it against the engine's zu.h so it cannot drift. A library that is too old to have a function this client calls is caught at load, by name, rather than at the call.

The columnar surface

Every column of a result is readable as one borrowed buffer over the engine's own memory. java.nio rather than MemorySegment, because a Java 17 caller can name a LongBuffer and the JNI provider can hand back the same thing without copying. Read-only, and in native byte order set explicitly, because asByteBuffer() returns a big-endian view and a wrong byte order is a wrong number rather than a failure.

Summing one integer column of a hundred thousand rows, M-series laptop, JDK 25:

HowPer row
r.longs(0) and a loop over the buffer0.45 ns
the same a chunk at a time4.1 ns
for (Row row : r) row.getLong(0)45 ns
r.stream().mapToLong(...)67 ns

A row at a time is a boundary crossing a cell, and a hundred of those cost about what one borrowed buffer costs. Both surfaces are here because both are the right answer to a different question.

Native access

From JDK 24 a downcall out of a module that was not granted native access warns, and the warning is on a path to becoming an error. The jar carries Enable-Native-Access: ALL-UNNAMED for the class path case, the module path case passes --enable-native-access=dev.zudb.ffm, and FfmProvider checks Module::isNativeAccessEnabled before the first downcall so that a caller who has neither gets an exception naming the flag rather than a JVM warning on stderr three frames from any of our code.

Why 25 and not 22

FFM was finalised in 22 and 22 has been out of support since September 2024. Targeting an unsupported release only moves the problem, so the provider is release 25 and the JNI provider will carry 17 through 21.

Tests

111, green against libzu built from the engine at its own HEAD. The API module tests need no library at all. The FFM tests find one through -Dzu.library, ZU_LIBRARY or a sibling engine checkout, and skip rather than fail when there is none, so a checkout with no engine beside it is still green.

The engine has no DDL, so nothing here writes a schema and the tests live on the expression and projection surface: RETURN, UNWIND, parameters, lists, records, and all seven temporal kinds out and back. range() does not exist either, so the bulk-row tests build a literal list.

CI builds the API artifact on 17, 21, 25 and 26, runs the whole suite on Linux and macOS on 25 and 26, once plainly and once with -ea -esa so that the bounds checks a MemorySegment does are actually on, and builds and runs the benchmarks because a benchmark is code nothing else compiles.

What changed in the README

The opening example used CREATE NODE TABLE and conn.loadCsv. The engine has no DDL and the ABI has no CSV call, so neither would run. The example is now a MATCH over a graph something else built, with a paragraph saying plainly what does and does not work today. The jextract claim is gone for the reason above.

Follow-ups, not in this PR

zu_version in the engine returns a hard-coded 0.0.1 rather than the crate version, so it will drift the first time the crate version moves. Worth a one-line fix in tamnd/zu.

A zu_abi_version() runtime symbol would let a binding with no C compile step ask instead of write the constant down.

Next here: the JNI provider for 17 through 21, the native artifacts, GraalVM reachability metadata, and Maven Central publishing.

Milestone: DX4, tamnd/zu#170.

Two artifacts. dev.zudb:zudb is the API, compiled to release 17, with no
native code in it and no FFM type anywhere in its public surface, so it is
the thing a caller on any supported JDK compiles against. dev.zudb:zudb-ffm
is the provider, compiled to release 25, and it is the only place that
calls libzu. A ServiceLoader picks between providers at run time and
application code never names one.
The downcall handles are written by hand against zu.h rather than generated
with jextract. The ABI here is around seventy functions with a stable shape,
and the decisions worth making are the ones a generator does not make: which
calls are Linker.Option.critical because they are short pure accessors, where
the out-parameter space comes from so that a query does not allocate, and how
a zu_error becomes a typed Java exception exactly once.
Three things in the binding are worth reading before the rest:
Scratch is a per-thread off-heap block that every call writes its
out-parameters into, reset with a bump pointer at the top of each call rather
than allocated per call. An Arena.ofConfined per call is a malloc and a free
on a path that is otherwise a handful of instructions, and no binding method
calls another, so there is nothing for a reset to invalidate.
zu_config is filled in by this client rather than by zu_config_init. The
struct is versioned by a struct_size the caller sets, and asking a newer
library to initialise a buffer sized by our header is asking it to write past
the end of it.
A column comes back as a read-only java.nio buffer over the engine's own
memory, in native byte order, because asByteBuffer hands back a big-endian
view and a wrong byte order is a wrong number rather than a failure. java.nio
rather than MemorySegment so that a Java 17 caller can name the type and so
that the JNI provider can return the same thing.
Native access is granted rather than assumed. The jar carries
Enable-Native-Access: ALL-UNNAMED for the class path case, the module path
case passes --enable-native-access=dev.zudb.ffm, and the provider checks
Module::isNativeAccessEnabled before the first downcall so that a caller who
has neither gets an exception naming the flag instead of a JVM warning three
frames from any of our code.
The FFM artifact targets 25 rather than the 22 that finalised the API,
because 22 has been out of support since September 2024.
111 tests, green against libzu built from the engine at HEAD. The suite skips
rather than fails when there is no library to find, so a checkout with no
engine beside it is still green.
The benchmarks say what the columnar surface is for. Summing one integer
column of a hundred thousand rows costs 0.45 ns a row through r.longs(0),
4.1 ns a row a chunk at a time, 45 ns a row through the Row iterator and
67 ns a row through the Stream. A row at a time is a boundary crossing a
cell, and a hundred of those cost about what one borrowed buffer costs.
CI builds the API artifact on 17, 21, 25 and 26, and runs the whole suite
against the engine at its own HEAD on Linux and macOS, once plainly and once
with assertions on everywhere. One step checks that the ABI version written
down in Zu.ABI_VERSION is the one the engine's zu.h declares, because
ZU_ABI_VERSION is a header macro rather than a symbol and a binding with no C
compile step has nowhere to read it from.
The README no longer opens with a CREATE NODE TABLE the engine cannot run.
@tamnd
tamnd merged commit 0dd94bc into mainAug 20, 2026
9 checks passed
@tamnd
tamnd deleted the java-core branch August 20, 2026 01:06
tamnd added a commit that referenced this pull request Aug 22, 2026
A binding holds native memory and the process that finds out later is
the user's. The suite here cannot see that: a test that closes nothing
and asserts on a message passes, and what it left behind is somebody
else's problem an hour into a run.
So the allocator is asked instead. A driver in the tck opens and closes
every handle this client hands out, failures beside successes, and
scripts/leaks.sh runs it with LeakSanitizer ahead of the JVM and reads
the report for blocks the engine allocated and nobody gave back.
The narrow question is the whole trick. A JVM does not free at exit, on
purpose, so pointing a leak checker at one that does nothing at all
reports about a megabyte in several thousand allocations and none of it
is anything a caller can act on. What is answerable is whether any
unfreed block came out of libzu, and a leak record carries the stack it
was allocated from, so it is answerable by reading frame #1.
Frame #1 rather than any frame, because of what the JNI row turned up.
Asking for a jmethodID allocates a JVM-side table entry the JVM never
frees, and the stack for it runs through the shim because the shim is
what asked. Any-frame matching called seven of those ours and they are
not: at #1 they are os::malloc in libjvm. A block the shim really did
allocate has the shim at #1 and is still caught. The count of records
let through is printed rather than dropped quietly.
The gate runs first and has to fail. A report with no libzu in it looks
the same whether nothing leaked, the sanitizer was never loaded, the
library was never called, or the driver died early, and three of those
four are green for the wrong reason. So the driver is run once with
ZU_LEAK_GATE=1, which drops a database, a connection, a statement, a
result, an appender and a frame on the floor, and the script stops if
that comes back clean.
On server3, both providers: the gate leaks 57 records naming
zu_execute, zu_appender_open and zu_database_open, and the clean run is
0 of 545 on Panama and 0 of 390 on JNI, against a JVM whose own report
is a megabyte either way. The full reactor is green beside it, 199 tck
cases and 12 Arrow.
Linux only. LeakSanitizer does not exist on macOS, and what covers the
same ground there is the lifecycle half of the misuse suite, which
counts open file descriptors either side of a few hundred failures and
needs no allocator to agree with it.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@tamnd
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Java SDK v1: the API, the Panama provider, tests and benchmarks - #1

Merged
tamnd merged 1 commit into
mainfrom
java-core
Aug 20, 2026
Merged

Java SDK v1: the API, the Panama provider, tests and benchmarks#1
tamnd merged 1 commit into
mainfrom
java-core

Conversation

@tamnd

Copy link
Copy Markdown
Owner

This is the first code in the repository. Two artifacts, one of which is what a caller compiles against and the other of which is the only thing that calls libzu.

dev.zudb:zudb is the API. Release 17, no native code, no FFM type anywhere in its public surface, so it is the artifact a caller on any supported JDK depends on. dev.zudb:zudb-ffm is the Panama provider, release 25. A ServiceLoader picks between providers at run time and application code never names one.

Why the bindings are hand written

The seed README said jextract would generate them. It does not, and it should not. The ABI here is around seventy functions with a shape that does not move, and the decisions worth making are the ones a generator does not make.

Which calls get Linker.Option.critical(false), for one. zu_result_rows, zu_value_type, zu_error_status and six others are pure accessors that read a field and return, and paying a thread state transition for each of them is most of what they cost.

Where the out-parameter space comes from, for another. Every one of these calls needs somewhere to put a pointer or a length, and an Arena.ofConfined() per call is a malloc and a free on a path that is otherwise a handful of instructions. Scratch is a per-thread off-heap block that a call resets with a bump pointer at the top and writes into. No binding method calls another and every out-parameter is read before the call returns, so there is nothing a reset can invalidate.

And how a zu_error becomes a Java exception, which happens in exactly one place. The subclass comes from the GQLSTATUS class rather than from the message: 42 is a ZuSyntaxException, 22 is a ZuDataException, 25 and 40 are ZuTransactionException, and so on down. The exception carries the whole diagnostic, so a caller reads code(), condition(), position() and retryable() rather than parsing prose. The error is freed in a finally, and there is a test that provokes a thousand failures and then keeps using the connection.

Two things about the ABI that are worth knowing

zu_config is filled in by this client rather than by zu_config_init. The struct is versioned by a struct_size the caller sets, and asking a newer library to initialise a buffer sized by our header is asking it to write past the end of it.

ZU_ABI_VERSION is a header macro rather than a symbol, so a binding with no C compile step has nowhere to read it from and has to write it down. Zu.ABI_VERSION is that copy, and there is a CI step that checks it against the engine's zu.h so it cannot drift. A library that is too old to have a function this client calls is caught at load, by name, rather than at the call.

The columnar surface

Every column of a result is readable as one borrowed buffer over the engine's own memory. java.nio rather than MemorySegment, because a Java 17 caller can name a LongBuffer and the JNI provider can hand back the same thing without copying. Read-only, and in native byte order set explicitly, because asByteBuffer() returns a big-endian view and a wrong byte order is a wrong number rather than a failure.

Summing one integer column of a hundred thousand rows, M-series laptop, JDK 25:

HowPer row
r.longs(0) and a loop over the buffer0.45 ns
the same a chunk at a time4.1 ns
for (Row row : r) row.getLong(0)45 ns
r.stream().mapToLong(...)67 ns

A row at a time is a boundary crossing a cell, and a hundred of those cost about what one borrowed buffer costs. Both surfaces are here because both are the right answer to a different question.

Native access

From JDK 24 a downcall out of a module that was not granted native access warns, and the warning is on a path to becoming an error. The jar carries Enable-Native-Access: ALL-UNNAMED for the class path case, the module path case passes --enable-native-access=dev.zudb.ffm, and FfmProvider checks Module::isNativeAccessEnabled before the first downcall so that a caller who has neither gets an exception naming the flag rather than a JVM warning on stderr three frames from any of our code.

Why 25 and not 22

FFM was finalised in 22 and 22 has been out of support since September 2024. Targeting an unsupported release only moves the problem, so the provider is release 25 and the JNI provider will carry 17 through 21.

Tests

111, green against libzu built from the engine at its own HEAD. The API module tests need no library at all. The FFM tests find one through -Dzu.library, ZU_LIBRARY or a sibling engine checkout, and skip rather than fail when there is none, so a checkout with no engine beside it is still green.

The engine has no DDL, so nothing here writes a schema and the tests live on the expression and projection surface: RETURN, UNWIND, parameters, lists, records, and all seven temporal kinds out and back. range() does not exist either, so the bulk-row tests build a literal list.

CI builds the API artifact on 17, 21, 25 and 26, runs the whole suite on Linux and macOS on 25 and 26, once plainly and once with -ea -esa so that the bounds checks a MemorySegment does are actually on, and builds and runs the benchmarks because a benchmark is code nothing else compiles.

What changed in the README

The opening example used CREATE NODE TABLE and conn.loadCsv. The engine has no DDL and the ABI has no CSV call, so neither would run. The example is now a MATCH over a graph something else built, with a paragraph saying plainly what does and does not work today. The jextract claim is gone for the reason above.

Follow-ups, not in this PR

zu_version in the engine returns a hard-coded 0.0.1 rather than the crate version, so it will drift the first time the crate version moves. Worth a one-line fix in tamnd/zu.

A zu_abi_version() runtime symbol would let a binding with no C compile step ask instead of write the constant down.

Next here: the JNI provider for 17 through 21, the native artifacts, GraalVM reachability metadata, and Maven Central publishing.

Milestone: DX4, tamnd/zu#170.

Two artifacts. dev.zudb:zudb is the API, compiled to release 17, with no
native code in it and no FFM type anywhere in its public surface, so it is
the thing a caller on any supported JDK compiles against. dev.zudb:zudb-ffm
is the provider, compiled to release 25, and it is the only place that
calls libzu. A ServiceLoader picks between providers at run time and
application code never names one.
The downcall handles are written by hand against zu.h rather than generated
with jextract. The ABI here is around seventy functions with a stable shape,
and the decisions worth making are the ones a generator does not make: which
calls are Linker.Option.critical because they are short pure accessors, where
the out-parameter space comes from so that a query does not allocate, and how
a zu_error becomes a typed Java exception exactly once.
Three things in the binding are worth reading before the rest:
Scratch is a per-thread off-heap block that every call writes its
out-parameters into, reset with a bump pointer at the top of each call rather
than allocated per call. An Arena.ofConfined per call is a malloc and a free
on a path that is otherwise a handful of instructions, and no binding method
calls another, so there is nothing for a reset to invalidate.
zu_config is filled in by this client rather than by zu_config_init. The
struct is versioned by a struct_size the caller sets, and asking a newer
library to initialise a buffer sized by our header is asking it to write past
the end of it.
A column comes back as a read-only java.nio buffer over the engine's own
memory, in native byte order, because asByteBuffer hands back a big-endian
view and a wrong byte order is a wrong number rather than a failure. java.nio
rather than MemorySegment so that a Java 17 caller can name the type and so
that the JNI provider can return the same thing.
Native access is granted rather than assumed. The jar carries
Enable-Native-Access: ALL-UNNAMED for the class path case, the module path
case passes --enable-native-access=dev.zudb.ffm, and the provider checks
Module::isNativeAccessEnabled before the first downcall so that a caller who
has neither gets an exception naming the flag instead of a JVM warning three
frames from any of our code.
The FFM artifact targets 25 rather than the 22 that finalised the API,
because 22 has been out of support since September 2024.
111 tests, green against libzu built from the engine at HEAD. The suite skips
rather than fails when there is no library to find, so a checkout with no
engine beside it is still green.
The benchmarks say what the columnar surface is for. Summing one integer
column of a hundred thousand rows costs 0.45 ns a row through r.longs(0),
4.1 ns a row a chunk at a time, 45 ns a row through the Row iterator and
67 ns a row through the Stream. A row at a time is a boundary crossing a
cell, and a hundred of those cost about what one borrowed buffer costs.
CI builds the API artifact on 17, 21, 25 and 26, and runs the whole suite
against the engine at its own HEAD on Linux and macOS, once plainly and once
with assertions on everywhere. One step checks that the ABI version written
down in Zu.ABI_VERSION is the one the engine's zu.h declares, because
ZU_ABI_VERSION is a header macro rather than a symbol and a binding with no C
compile step has nowhere to read it from.
The README no longer opens with a CREATE NODE TABLE the engine cannot run.
@tamnd
tamnd merged commit 0dd94bc into mainAug 20, 2026
9 checks passed
@tamnd
tamnd deleted the java-core branch August 20, 2026 01:06
tamnd added a commit that referenced this pull request Aug 22, 2026
A binding holds native memory and the process that finds out later is
the user's. The suite here cannot see that: a test that closes nothing
and asserts on a message passes, and what it left behind is somebody
else's problem an hour into a run.
So the allocator is asked instead. A driver in the tck opens and closes
every handle this client hands out, failures beside successes, and
scripts/leaks.sh runs it with LeakSanitizer ahead of the JVM and reads
the report for blocks the engine allocated and nobody gave back.
The narrow question is the whole trick. A JVM does not free at exit, on
purpose, so pointing a leak checker at one that does nothing at all
reports about a megabyte in several thousand allocations and none of it
is anything a caller can act on. What is answerable is whether any
unfreed block came out of libzu, and a leak record carries the stack it
was allocated from, so it is answerable by reading frame #1.
Frame #1 rather than any frame, because of what the JNI row turned up.
Asking for a jmethodID allocates a JVM-side table entry the JVM never
frees, and the stack for it runs through the shim because the shim is
what asked. Any-frame matching called seven of those ours and they are
not: at #1 they are os::malloc in libjvm. A block the shim really did
allocate has the shim at #1 and is still caught. The count of records
let through is printed rather than dropped quietly.
The gate runs first and has to fail. A report with no libzu in it looks
the same whether nothing leaked, the sanitizer was never loaded, the
library was never called, or the driver died early, and three of those
four are green for the wrong reason. So the driver is run once with
ZU_LEAK_GATE=1, which drops a database, a connection, a statement, a
result, an appender and a frame on the floor, and the script stops if
that comes back clean.
On server3, both providers: the gate leaks 57 records naming
zu_execute, zu_appender_open and zu_database_open, and the clean run is
0 of 545 on Panama and 0 of 390 on JNI, against a JVM whose own report
is a megabyte either way. The full reactor is green beside it, 199 tck
cases and 12 Arrow.
Linux only. LeakSanitizer does not exist on macOS, and what covers the
same ground there is the lifecycle half of the misuse suite, which
counts open file descriptors either side of a few hundred failures and
needs no allocator to agree with it.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@tamnd
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Java SDK v1: the API, the Panama provider, tests and benchmarks - #1

Merged
tamnd merged 1 commit into
mainfrom
java-core
Aug 20, 2026
Merged

Java SDK v1: the API, the Panama provider, tests and benchmarks#1
tamnd merged 1 commit into
mainfrom
java-core

Conversation

@tamnd

Copy link
Copy Markdown
Owner

This is the first code in the repository. Two artifacts, one of which is what a caller compiles against and the other of which is the only thing that calls libzu.

dev.zudb:zudb is the API. Release 17, no native code, no FFM type anywhere in its public surface, so it is the artifact a caller on any supported JDK depends on. dev.zudb:zudb-ffm is the Panama provider, release 25. A ServiceLoader picks between providers at run time and application code never names one.

Why the bindings are hand written

The seed README said jextract would generate them. It does not, and it should not. The ABI here is around seventy functions with a shape that does not move, and the decisions worth making are the ones a generator does not make.

Which calls get Linker.Option.critical(false), for one. zu_result_rows, zu_value_type, zu_error_status and six others are pure accessors that read a field and return, and paying a thread state transition for each of them is most of what they cost.

Where the out-parameter space comes from, for another. Every one of these calls needs somewhere to put a pointer or a length, and an Arena.ofConfined() per call is a malloc and a free on a path that is otherwise a handful of instructions. Scratch is a per-thread off-heap block that a call resets with a bump pointer at the top and writes into. No binding method calls another and every out-parameter is read before the call returns, so there is nothing a reset can invalidate.

And how a zu_error becomes a Java exception, which happens in exactly one place. The subclass comes from the GQLSTATUS class rather than from the message: 42 is a ZuSyntaxException, 22 is a ZuDataException, 25 and 40 are ZuTransactionException, and so on down. The exception carries the whole diagnostic, so a caller reads code(), condition(), position() and retryable() rather than parsing prose. The error is freed in a finally, and there is a test that provokes a thousand failures and then keeps using the connection.

Two things about the ABI that are worth knowing

zu_config is filled in by this client rather than by zu_config_init. The struct is versioned by a struct_size the caller sets, and asking a newer library to initialise a buffer sized by our header is asking it to write past the end of it.

ZU_ABI_VERSION is a header macro rather than a symbol, so a binding with no C compile step has nowhere to read it from and has to write it down. Zu.ABI_VERSION is that copy, and there is a CI step that checks it against the engine's zu.h so it cannot drift. A library that is too old to have a function this client calls is caught at load, by name, rather than at the call.

The columnar surface

Every column of a result is readable as one borrowed buffer over the engine's own memory. java.nio rather than MemorySegment, because a Java 17 caller can name a LongBuffer and the JNI provider can hand back the same thing without copying. Read-only, and in native byte order set explicitly, because asByteBuffer() returns a big-endian view and a wrong byte order is a wrong number rather than a failure.

Summing one integer column of a hundred thousand rows, M-series laptop, JDK 25:

HowPer row
r.longs(0) and a loop over the buffer0.45 ns
the same a chunk at a time4.1 ns
for (Row row : r) row.getLong(0)45 ns
r.stream().mapToLong(...)67 ns

A row at a time is a boundary crossing a cell, and a hundred of those cost about what one borrowed buffer costs. Both surfaces are here because both are the right answer to a different question.

Native access

From JDK 24 a downcall out of a module that was not granted native access warns, and the warning is on a path to becoming an error. The jar carries Enable-Native-Access: ALL-UNNAMED for the class path case, the module path case passes --enable-native-access=dev.zudb.ffm, and FfmProvider checks Module::isNativeAccessEnabled before the first downcall so that a caller who has neither gets an exception naming the flag rather than a JVM warning on stderr three frames from any of our code.

Why 25 and not 22

FFM was finalised in 22 and 22 has been out of support since September 2024. Targeting an unsupported release only moves the problem, so the provider is release 25 and the JNI provider will carry 17 through 21.

Tests

111, green against libzu built from the engine at its own HEAD. The API module tests need no library at all. The FFM tests find one through -Dzu.library, ZU_LIBRARY or a sibling engine checkout, and skip rather than fail when there is none, so a checkout with no engine beside it is still green.

The engine has no DDL, so nothing here writes a schema and the tests live on the expression and projection surface: RETURN, UNWIND, parameters, lists, records, and all seven temporal kinds out and back. range() does not exist either, so the bulk-row tests build a literal list.

CI builds the API artifact on 17, 21, 25 and 26, runs the whole suite on Linux and macOS on 25 and 26, once plainly and once with -ea -esa so that the bounds checks a MemorySegment does are actually on, and builds and runs the benchmarks because a benchmark is code nothing else compiles.

What changed in the README

The opening example used CREATE NODE TABLE and conn.loadCsv. The engine has no DDL and the ABI has no CSV call, so neither would run. The example is now a MATCH over a graph something else built, with a paragraph saying plainly what does and does not work today. The jextract claim is gone for the reason above.

Follow-ups, not in this PR

zu_version in the engine returns a hard-coded 0.0.1 rather than the crate version, so it will drift the first time the crate version moves. Worth a one-line fix in tamnd/zu.

A zu_abi_version() runtime symbol would let a binding with no C compile step ask instead of write the constant down.

Next here: the JNI provider for 17 through 21, the native artifacts, GraalVM reachability metadata, and Maven Central publishing.

Milestone: DX4, tamnd/zu#170.

Two artifacts. dev.zudb:zudb is the API, compiled to release 17, with no
native code in it and no FFM type anywhere in its public surface, so it is
the thing a caller on any supported JDK compiles against. dev.zudb:zudb-ffm
is the provider, compiled to release 25, and it is the only place that
calls libzu. A ServiceLoader picks between providers at run time and
application code never names one.
The downcall handles are written by hand against zu.h rather than generated
with jextract. The ABI here is around seventy functions with a stable shape,
and the decisions worth making are the ones a generator does not make: which
calls are Linker.Option.critical because they are short pure accessors, where
the out-parameter space comes from so that a query does not allocate, and how
a zu_error becomes a typed Java exception exactly once.
Three things in the binding are worth reading before the rest:
Scratch is a per-thread off-heap block that every call writes its
out-parameters into, reset with a bump pointer at the top of each call rather
than allocated per call. An Arena.ofConfined per call is a malloc and a free
on a path that is otherwise a handful of instructions, and no binding method
calls another, so there is nothing for a reset to invalidate.
zu_config is filled in by this client rather than by zu_config_init. The
struct is versioned by a struct_size the caller sets, and asking a newer
library to initialise a buffer sized by our header is asking it to write past
the end of it.
A column comes back as a read-only java.nio buffer over the engine's own
memory, in native byte order, because asByteBuffer hands back a big-endian
view and a wrong byte order is a wrong number rather than a failure. java.nio
rather than MemorySegment so that a Java 17 caller can name the type and so
that the JNI provider can return the same thing.
Native access is granted rather than assumed. The jar carries
Enable-Native-Access: ALL-UNNAMED for the class path case, the module path
case passes --enable-native-access=dev.zudb.ffm, and the provider checks
Module::isNativeAccessEnabled before the first downcall so that a caller who
has neither gets an exception naming the flag instead of a JVM warning three
frames from any of our code.
The FFM artifact targets 25 rather than the 22 that finalised the API,
because 22 has been out of support since September 2024.
111 tests, green against libzu built from the engine at HEAD. The suite skips
rather than fails when there is no library to find, so a checkout with no
engine beside it is still green.
The benchmarks say what the columnar surface is for. Summing one integer
column of a hundred thousand rows costs 0.45 ns a row through r.longs(0),
4.1 ns a row a chunk at a time, 45 ns a row through the Row iterator and
67 ns a row through the Stream. A row at a time is a boundary crossing a
cell, and a hundred of those cost about what one borrowed buffer costs.
CI builds the API artifact on 17, 21, 25 and 26, and runs the whole suite
against the engine at its own HEAD on Linux and macOS, once plainly and once
with assertions on everywhere. One step checks that the ABI version written
down in Zu.ABI_VERSION is the one the engine's zu.h declares, because
ZU_ABI_VERSION is a header macro rather than a symbol and a binding with no C
compile step has nowhere to read it from.
The README no longer opens with a CREATE NODE TABLE the engine cannot run.
@tamnd
tamnd merged commit 0dd94bc into mainAug 20, 2026
9 checks passed
@tamnd
tamnd deleted the java-core branch August 20, 2026 01:06
tamnd added a commit that referenced this pull request Aug 22, 2026
A binding holds native memory and the process that finds out later is
the user's. The suite here cannot see that: a test that closes nothing
and asserts on a message passes, and what it left behind is somebody
else's problem an hour into a run.
So the allocator is asked instead. A driver in the tck opens and closes
every handle this client hands out, failures beside successes, and
scripts/leaks.sh runs it with LeakSanitizer ahead of the JVM and reads
the report for blocks the engine allocated and nobody gave back.
The narrow question is the whole trick. A JVM does not free at exit, on
purpose, so pointing a leak checker at one that does nothing at all
reports about a megabyte in several thousand allocations and none of it
is anything a caller can act on. What is answerable is whether any
unfreed block came out of libzu, and a leak record carries the stack it
was allocated from, so it is answerable by reading frame #1.
Frame #1 rather than any frame, because of what the JNI row turned up.
Asking for a jmethodID allocates a JVM-side table entry the JVM never
frees, and the stack for it runs through the shim because the shim is
what asked. Any-frame matching called seven of those ours and they are
not: at #1 they are os::malloc in libjvm. A block the shim really did
allocate has the shim at #1 and is still caught. The count of records
let through is printed rather than dropped quietly.
The gate runs first and has to fail. A report with no libzu in it looks
the same whether nothing leaked, the sanitizer was never loaded, the
library was never called, or the driver died early, and three of those
four are green for the wrong reason. So the driver is run once with
ZU_LEAK_GATE=1, which drops a database, a connection, a statement, a
result, an appender and a frame on the floor, and the script stops if
that comes back clean.
On server3, both providers: the gate leaks 57 records naming
zu_execute, zu_appender_open and zu_database_open, and the clean run is
0 of 545 on Panama and 0 of 390 on JNI, against a JVM whose own report
is a megabyte either way. The full reactor is green beside it, 199 tck
cases and 12 Arrow.
Linux only. LeakSanitizer does not exist on macOS, and what covers the
same ground there is the lifecycle half of the misuse suite, which
counts open file descriptors either side of a few hundred failures and
needs no allocator to agree with it.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@tamnd
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Java SDK v1: the API, the Panama provider, tests and benchmarks - #1

Merged
tamnd merged 1 commit into
mainfrom
java-core
Aug 20, 2026
Merged

Java SDK v1: the API, the Panama provider, tests and benchmarks#1
tamnd merged 1 commit into
mainfrom
java-core

Conversation

@tamnd

Copy link
Copy Markdown
Owner

This is the first code in the repository. Two artifacts, one of which is what a caller compiles against and the other of which is the only thing that calls libzu.

dev.zudb:zudb is the API. Release 17, no native code, no FFM type anywhere in its public surface, so it is the artifact a caller on any supported JDK depends on. dev.zudb:zudb-ffm is the Panama provider, release 25. A ServiceLoader picks between providers at run time and application code never names one.

Why the bindings are hand written

The seed README said jextract would generate them. It does not, and it should not. The ABI here is around seventy functions with a shape that does not move, and the decisions worth making are the ones a generator does not make.

Which calls get Linker.Option.critical(false), for one. zu_result_rows, zu_value_type, zu_error_status and six others are pure accessors that read a field and return, and paying a thread state transition for each of them is most of what they cost.

Where the out-parameter space comes from, for another. Every one of these calls needs somewhere to put a pointer or a length, and an Arena.ofConfined() per call is a malloc and a free on a path that is otherwise a handful of instructions. Scratch is a per-thread off-heap block that a call resets with a bump pointer at the top and writes into. No binding method calls another and every out-parameter is read before the call returns, so there is nothing a reset can invalidate.

And how a zu_error becomes a Java exception, which happens in exactly one place. The subclass comes from the GQLSTATUS class rather than from the message: 42 is a ZuSyntaxException, 22 is a ZuDataException, 25 and 40 are ZuTransactionException, and so on down. The exception carries the whole diagnostic, so a caller reads code(), condition(), position() and retryable() rather than parsing prose. The error is freed in a finally, and there is a test that provokes a thousand failures and then keeps using the connection.

Two things about the ABI that are worth knowing

zu_config is filled in by this client rather than by zu_config_init. The struct is versioned by a struct_size the caller sets, and asking a newer library to initialise a buffer sized by our header is asking it to write past the end of it.

ZU_ABI_VERSION is a header macro rather than a symbol, so a binding with no C compile step has nowhere to read it from and has to write it down. Zu.ABI_VERSION is that copy, and there is a CI step that checks it against the engine's zu.h so it cannot drift. A library that is too old to have a function this client calls is caught at load, by name, rather than at the call.

The columnar surface

Every column of a result is readable as one borrowed buffer over the engine's own memory. java.nio rather than MemorySegment, because a Java 17 caller can name a LongBuffer and the JNI provider can hand back the same thing without copying. Read-only, and in native byte order set explicitly, because asByteBuffer() returns a big-endian view and a wrong byte order is a wrong number rather than a failure.

Summing one integer column of a hundred thousand rows, M-series laptop, JDK 25:

HowPer row
r.longs(0) and a loop over the buffer0.45 ns
the same a chunk at a time4.1 ns
for (Row row : r) row.getLong(0)45 ns
r.stream().mapToLong(...)67 ns

A row at a time is a boundary crossing a cell, and a hundred of those cost about what one borrowed buffer costs. Both surfaces are here because both are the right answer to a different question.

Native access

From JDK 24 a downcall out of a module that was not granted native access warns, and the warning is on a path to becoming an error. The jar carries Enable-Native-Access: ALL-UNNAMED for the class path case, the module path case passes --enable-native-access=dev.zudb.ffm, and FfmProvider checks Module::isNativeAccessEnabled before the first downcall so that a caller who has neither gets an exception naming the flag rather than a JVM warning on stderr three frames from any of our code.

Why 25 and not 22

FFM was finalised in 22 and 22 has been out of support since September 2024. Targeting an unsupported release only moves the problem, so the provider is release 25 and the JNI provider will carry 17 through 21.

Tests

111, green against libzu built from the engine at its own HEAD. The API module tests need no library at all. The FFM tests find one through -Dzu.library, ZU_LIBRARY or a sibling engine checkout, and skip rather than fail when there is none, so a checkout with no engine beside it is still green.

The engine has no DDL, so nothing here writes a schema and the tests live on the expression and projection surface: RETURN, UNWIND, parameters, lists, records, and all seven temporal kinds out and back. range() does not exist either, so the bulk-row tests build a literal list.

CI builds the API artifact on 17, 21, 25 and 26, runs the whole suite on Linux and macOS on 25 and 26, once plainly and once with -ea -esa so that the bounds checks a MemorySegment does are actually on, and builds and runs the benchmarks because a benchmark is code nothing else compiles.

What changed in the README

The opening example used CREATE NODE TABLE and conn.loadCsv. The engine has no DDL and the ABI has no CSV call, so neither would run. The example is now a MATCH over a graph something else built, with a paragraph saying plainly what does and does not work today. The jextract claim is gone for the reason above.

Follow-ups, not in this PR

zu_version in the engine returns a hard-coded 0.0.1 rather than the crate version, so it will drift the first time the crate version moves. Worth a one-line fix in tamnd/zu.

A zu_abi_version() runtime symbol would let a binding with no C compile step ask instead of write the constant down.

Next here: the JNI provider for 17 through 21, the native artifacts, GraalVM reachability metadata, and Maven Central publishing.

Milestone: DX4, tamnd/zu#170.

Two artifacts. dev.zudb:zudb is the API, compiled to release 17, with no
native code in it and no FFM type anywhere in its public surface, so it is
the thing a caller on any supported JDK compiles against. dev.zudb:zudb-ffm
is the provider, compiled to release 25, and it is the only place that
calls libzu. A ServiceLoader picks between providers at run time and
application code never names one.
The downcall handles are written by hand against zu.h rather than generated
with jextract. The ABI here is around seventy functions with a stable shape,
and the decisions worth making are the ones a generator does not make: which
calls are Linker.Option.critical because they are short pure accessors, where
the out-parameter space comes from so that a query does not allocate, and how
a zu_error becomes a typed Java exception exactly once.
Three things in the binding are worth reading before the rest:
Scratch is a per-thread off-heap block that every call writes its
out-parameters into, reset with a bump pointer at the top of each call rather
than allocated per call. An Arena.ofConfined per call is a malloc and a free
on a path that is otherwise a handful of instructions, and no binding method
calls another, so there is nothing for a reset to invalidate.
zu_config is filled in by this client rather than by zu_config_init. The
struct is versioned by a struct_size the caller sets, and asking a newer
library to initialise a buffer sized by our header is asking it to write past
the end of it.
A column comes back as a read-only java.nio buffer over the engine's own
memory, in native byte order, because asByteBuffer hands back a big-endian
view and a wrong byte order is a wrong number rather than a failure. java.nio
rather than MemorySegment so that a Java 17 caller can name the type and so
that the JNI provider can return the same thing.
Native access is granted rather than assumed. The jar carries
Enable-Native-Access: ALL-UNNAMED for the class path case, the module path
case passes --enable-native-access=dev.zudb.ffm, and the provider checks
Module::isNativeAccessEnabled before the first downcall so that a caller who
has neither gets an exception naming the flag instead of a JVM warning three
frames from any of our code.
The FFM artifact targets 25 rather than the 22 that finalised the API,
because 22 has been out of support since September 2024.
111 tests, green against libzu built from the engine at HEAD. The suite skips
rather than fails when there is no library to find, so a checkout with no
engine beside it is still green.
The benchmarks say what the columnar surface is for. Summing one integer
column of a hundred thousand rows costs 0.45 ns a row through r.longs(0),
4.1 ns a row a chunk at a time, 45 ns a row through the Row iterator and
67 ns a row through the Stream. A row at a time is a boundary crossing a
cell, and a hundred of those cost about what one borrowed buffer costs.
CI builds the API artifact on 17, 21, 25 and 26, and runs the whole suite
against the engine at its own HEAD on Linux and macOS, once plainly and once
with assertions on everywhere. One step checks that the ABI version written
down in Zu.ABI_VERSION is the one the engine's zu.h declares, because
ZU_ABI_VERSION is a header macro rather than a symbol and a binding with no C
compile step has nowhere to read it from.
The README no longer opens with a CREATE NODE TABLE the engine cannot run.
@tamnd
tamnd merged commit 0dd94bc into mainAug 20, 2026
9 checks passed
@tamnd
tamnd deleted the java-core branch August 20, 2026 01:06
tamnd added a commit that referenced this pull request Aug 22, 2026
A binding holds native memory and the process that finds out later is
the user's. The suite here cannot see that: a test that closes nothing
and asserts on a message passes, and what it left behind is somebody
else's problem an hour into a run.
So the allocator is asked instead. A driver in the tck opens and closes
every handle this client hands out, failures beside successes, and
scripts/leaks.sh runs it with LeakSanitizer ahead of the JVM and reads
the report for blocks the engine allocated and nobody gave back.
The narrow question is the whole trick. A JVM does not free at exit, on
purpose, so pointing a leak checker at one that does nothing at all
reports about a megabyte in several thousand allocations and none of it
is anything a caller can act on. What is answerable is whether any
unfreed block came out of libzu, and a leak record carries the stack it
was allocated from, so it is answerable by reading frame #1.
Frame #1 rather than any frame, because of what the JNI row turned up.
Asking for a jmethodID allocates a JVM-side table entry the JVM never
frees, and the stack for it runs through the shim because the shim is
what asked. Any-frame matching called seven of those ours and they are
not: at #1 they are os::malloc in libjvm. A block the shim really did
allocate has the shim at #1 and is still caught. The count of records
let through is printed rather than dropped quietly.
The gate runs first and has to fail. A report with no libzu in it looks
the same whether nothing leaked, the sanitizer was never loaded, the
library was never called, or the driver died early, and three of those
four are green for the wrong reason. So the driver is run once with
ZU_LEAK_GATE=1, which drops a database, a connection, a statement, a
result, an appender and a frame on the floor, and the script stops if
that comes back clean.
On server3, both providers: the gate leaks 57 records naming
zu_execute, zu_appender_open and zu_database_open, and the clean run is
0 of 545 on Panama and 0 of 390 on JNI, against a JVM whose own report
is a megabyte either way. The full reactor is green beside it, 199 tck
cases and 12 Arrow.
Linux only. LeakSanitizer does not exist on macOS, and what covers the
same ground there is the lifecycle half of the misuse suite, which
counts open file descriptors either side of a few hundred failures and
needs no allocator to agree with it.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@tamnd
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Java SDK v1: the API, the Panama provider, tests and benchmarks - #1

Merged
tamnd merged 1 commit into
mainfrom
java-core
Aug 20, 2026
Merged

Java SDK v1: the API, the Panama provider, tests and benchmarks#1
tamnd merged 1 commit into
mainfrom
java-core

Conversation

@tamnd

Copy link
Copy Markdown
Owner

This is the first code in the repository. Two artifacts, one of which is what a caller compiles against and the other of which is the only thing that calls libzu.

dev.zudb:zudb is the API. Release 17, no native code, no FFM type anywhere in its public surface, so it is the artifact a caller on any supported JDK depends on. dev.zudb:zudb-ffm is the Panama provider, release 25. A ServiceLoader picks between providers at run time and application code never names one.

Why the bindings are hand written

The seed README said jextract would generate them. It does not, and it should not. The ABI here is around seventy functions with a shape that does not move, and the decisions worth making are the ones a generator does not make.

Which calls get Linker.Option.critical(false), for one. zu_result_rows, zu_value_type, zu_error_status and six others are pure accessors that read a field and return, and paying a thread state transition for each of them is most of what they cost.

Where the out-parameter space comes from, for another. Every one of these calls needs somewhere to put a pointer or a length, and an Arena.ofConfined() per call is a malloc and a free on a path that is otherwise a handful of instructions. Scratch is a per-thread off-heap block that a call resets with a bump pointer at the top and writes into. No binding method calls another and every out-parameter is read before the call returns, so there is nothing a reset can invalidate.

And how a zu_error becomes a Java exception, which happens in exactly one place. The subclass comes from the GQLSTATUS class rather than from the message: 42 is a ZuSyntaxException, 22 is a ZuDataException, 25 and 40 are ZuTransactionException, and so on down. The exception carries the whole diagnostic, so a caller reads code(), condition(), position() and retryable() rather than parsing prose. The error is freed in a finally, and there is a test that provokes a thousand failures and then keeps using the connection.

Two things about the ABI that are worth knowing

zu_config is filled in by this client rather than by zu_config_init. The struct is versioned by a struct_size the caller sets, and asking a newer library to initialise a buffer sized by our header is asking it to write past the end of it.

ZU_ABI_VERSION is a header macro rather than a symbol, so a binding with no C compile step has nowhere to read it from and has to write it down. Zu.ABI_VERSION is that copy, and there is a CI step that checks it against the engine's zu.h so it cannot drift. A library that is too old to have a function this client calls is caught at load, by name, rather than at the call.

The columnar surface

Every column of a result is readable as one borrowed buffer over the engine's own memory. java.nio rather than MemorySegment, because a Java 17 caller can name a LongBuffer and the JNI provider can hand back the same thing without copying. Read-only, and in native byte order set explicitly, because asByteBuffer() returns a big-endian view and a wrong byte order is a wrong number rather than a failure.

Summing one integer column of a hundred thousand rows, M-series laptop, JDK 25:

HowPer row
r.longs(0) and a loop over the buffer0.45 ns
the same a chunk at a time4.1 ns
for (Row row : r) row.getLong(0)45 ns
r.stream().mapToLong(...)67 ns

A row at a time is a boundary crossing a cell, and a hundred of those cost about what one borrowed buffer costs. Both surfaces are here because both are the right answer to a different question.

Native access

From JDK 24 a downcall out of a module that was not granted native access warns, and the warning is on a path to becoming an error. The jar carries Enable-Native-Access: ALL-UNNAMED for the class path case, the module path case passes --enable-native-access=dev.zudb.ffm, and FfmProvider checks Module::isNativeAccessEnabled before the first downcall so that a caller who has neither gets an exception naming the flag rather than a JVM warning on stderr three frames from any of our code.

Why 25 and not 22

FFM was finalised in 22 and 22 has been out of support since September 2024. Targeting an unsupported release only moves the problem, so the provider is release 25 and the JNI provider will carry 17 through 21.

Tests

111, green against libzu built from the engine at its own HEAD. The API module tests need no library at all. The FFM tests find one through -Dzu.library, ZU_LIBRARY or a sibling engine checkout, and skip rather than fail when there is none, so a checkout with no engine beside it is still green.

The engine has no DDL, so nothing here writes a schema and the tests live on the expression and projection surface: RETURN, UNWIND, parameters, lists, records, and all seven temporal kinds out and back. range() does not exist either, so the bulk-row tests build a literal list.

CI builds the API artifact on 17, 21, 25 and 26, runs the whole suite on Linux and macOS on 25 and 26, once plainly and once with -ea -esa so that the bounds checks a MemorySegment does are actually on, and builds and runs the benchmarks because a benchmark is code nothing else compiles.

What changed in the README

The opening example used CREATE NODE TABLE and conn.loadCsv. The engine has no DDL and the ABI has no CSV call, so neither would run. The example is now a MATCH over a graph something else built, with a paragraph saying plainly what does and does not work today. The jextract claim is gone for the reason above.

Follow-ups, not in this PR

zu_version in the engine returns a hard-coded 0.0.1 rather than the crate version, so it will drift the first time the crate version moves. Worth a one-line fix in tamnd/zu.

A zu_abi_version() runtime symbol would let a binding with no C compile step ask instead of write the constant down.

Next here: the JNI provider for 17 through 21, the native artifacts, GraalVM reachability metadata, and Maven Central publishing.

Milestone: DX4, tamnd/zu#170.

Two artifacts. dev.zudb:zudb is the API, compiled to release 17, with no
native code in it and no FFM type anywhere in its public surface, so it is
the thing a caller on any supported JDK compiles against. dev.zudb:zudb-ffm
is the provider, compiled to release 25, and it is the only place that
calls libzu. A ServiceLoader picks between providers at run time and
application code never names one.
The downcall handles are written by hand against zu.h rather than generated
with jextract. The ABI here is around seventy functions with a stable shape,
and the decisions worth making are the ones a generator does not make: which
calls are Linker.Option.critical because they are short pure accessors, where
the out-parameter space comes from so that a query does not allocate, and how
a zu_error becomes a typed Java exception exactly once.
Three things in the binding are worth reading before the rest:
Scratch is a per-thread off-heap block that every call writes its
out-parameters into, reset with a bump pointer at the top of each call rather
than allocated per call. An Arena.ofConfined per call is a malloc and a free
on a path that is otherwise a handful of instructions, and no binding method
calls another, so there is nothing for a reset to invalidate.
zu_config is filled in by this client rather than by zu_config_init. The
struct is versioned by a struct_size the caller sets, and asking a newer
library to initialise a buffer sized by our header is asking it to write past
the end of it.
A column comes back as a read-only java.nio buffer over the engine's own
memory, in native byte order, because asByteBuffer hands back a big-endian
view and a wrong byte order is a wrong number rather than a failure. java.nio
rather than MemorySegment so that a Java 17 caller can name the type and so
that the JNI provider can return the same thing.
Native access is granted rather than assumed. The jar carries
Enable-Native-Access: ALL-UNNAMED for the class path case, the module path
case passes --enable-native-access=dev.zudb.ffm, and the provider checks
Module::isNativeAccessEnabled before the first downcall so that a caller who
has neither gets an exception naming the flag instead of a JVM warning three
frames from any of our code.
The FFM artifact targets 25 rather than the 22 that finalised the API,
because 22 has been out of support since September 2024.
111 tests, green against libzu built from the engine at HEAD. The suite skips
rather than fails when there is no library to find, so a checkout with no
engine beside it is still green.
The benchmarks say what the columnar surface is for. Summing one integer
column of a hundred thousand rows costs 0.45 ns a row through r.longs(0),
4.1 ns a row a chunk at a time, 45 ns a row through the Row iterator and
67 ns a row through the Stream. A row at a time is a boundary crossing a
cell, and a hundred of those cost about what one borrowed buffer costs.
CI builds the API artifact on 17, 21, 25 and 26, and runs the whole suite
against the engine at its own HEAD on Linux and macOS, once plainly and once
with assertions on everywhere. One step checks that the ABI version written
down in Zu.ABI_VERSION is the one the engine's zu.h declares, because
ZU_ABI_VERSION is a header macro rather than a symbol and a binding with no C
compile step has nowhere to read it from.
The README no longer opens with a CREATE NODE TABLE the engine cannot run.
@tamnd
tamnd merged commit 0dd94bc into mainAug 20, 2026
9 checks passed
@tamnd
tamnd deleted the java-core branch August 20, 2026 01:06
tamnd added a commit that referenced this pull request Aug 22, 2026
A binding holds native memory and the process that finds out later is
the user's. The suite here cannot see that: a test that closes nothing
and asserts on a message passes, and what it left behind is somebody
else's problem an hour into a run.
So the allocator is asked instead. A driver in the tck opens and closes
every handle this client hands out, failures beside successes, and
scripts/leaks.sh runs it with LeakSanitizer ahead of the JVM and reads
the report for blocks the engine allocated and nobody gave back.
The narrow question is the whole trick. A JVM does not free at exit, on
purpose, so pointing a leak checker at one that does nothing at all
reports about a megabyte in several thousand allocations and none of it
is anything a caller can act on. What is answerable is whether any
unfreed block came out of libzu, and a leak record carries the stack it
was allocated from, so it is answerable by reading frame #1.
Frame #1 rather than any frame, because of what the JNI row turned up.
Asking for a jmethodID allocates a JVM-side table entry the JVM never
frees, and the stack for it runs through the shim because the shim is
what asked. Any-frame matching called seven of those ours and they are
not: at #1 they are os::malloc in libjvm. A block the shim really did
allocate has the shim at #1 and is still caught. The count of records
let through is printed rather than dropped quietly.
The gate runs first and has to fail. A report with no libzu in it looks
the same whether nothing leaked, the sanitizer was never loaded, the
library was never called, or the driver died early, and three of those
four are green for the wrong reason. So the driver is run once with
ZU_LEAK_GATE=1, which drops a database, a connection, a statement, a
result, an appender and a frame on the floor, and the script stops if
that comes back clean.
On server3, both providers: the gate leaks 57 records naming
zu_execute, zu_appender_open and zu_database_open, and the clean run is
0 of 545 on Panama and 0 of 390 on JNI, against a JVM whose own report
is a megabyte either way. The full reactor is green beside it, 199 tck
cases and 12 Arrow.
Linux only. LeakSanitizer does not exist on macOS, and what covers the
same ground there is the lifecycle half of the misuse suite, which
counts open file descriptors either side of a few hundred failures and
needs no allocator to agree with it.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@tamnd
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Java SDK v1: the API, the Panama provider, tests and benchmarks - #1

Merged
tamnd merged 1 commit into
mainfrom
java-core
Aug 20, 2026
Merged

Java SDK v1: the API, the Panama provider, tests and benchmarks#1
tamnd merged 1 commit into
mainfrom
java-core

Conversation

@tamnd

Copy link
Copy Markdown
Owner

This is the first code in the repository. Two artifacts, one of which is what a caller compiles against and the other of which is the only thing that calls libzu.

dev.zudb:zudb is the API. Release 17, no native code, no FFM type anywhere in its public surface, so it is the artifact a caller on any supported JDK depends on. dev.zudb:zudb-ffm is the Panama provider, release 25. A ServiceLoader picks between providers at run time and application code never names one.

Why the bindings are hand written

The seed README said jextract would generate them. It does not, and it should not. The ABI here is around seventy functions with a shape that does not move, and the decisions worth making are the ones a generator does not make.

Which calls get Linker.Option.critical(false), for one. zu_result_rows, zu_value_type, zu_error_status and six others are pure accessors that read a field and return, and paying a thread state transition for each of them is most of what they cost.

Where the out-parameter space comes from, for another. Every one of these calls needs somewhere to put a pointer or a length, and an Arena.ofConfined() per call is a malloc and a free on a path that is otherwise a handful of instructions. Scratch is a per-thread off-heap block that a call resets with a bump pointer at the top and writes into. No binding method calls another and every out-parameter is read before the call returns, so there is nothing a reset can invalidate.

And how a zu_error becomes a Java exception, which happens in exactly one place. The subclass comes from the GQLSTATUS class rather than from the message: 42 is a ZuSyntaxException, 22 is a ZuDataException, 25 and 40 are ZuTransactionException, and so on down. The exception carries the whole diagnostic, so a caller reads code(), condition(), position() and retryable() rather than parsing prose. The error is freed in a finally, and there is a test that provokes a thousand failures and then keeps using the connection.

Two things about the ABI that are worth knowing

zu_config is filled in by this client rather than by zu_config_init. The struct is versioned by a struct_size the caller sets, and asking a newer library to initialise a buffer sized by our header is asking it to write past the end of it.

ZU_ABI_VERSION is a header macro rather than a symbol, so a binding with no C compile step has nowhere to read it from and has to write it down. Zu.ABI_VERSION is that copy, and there is a CI step that checks it against the engine's zu.h so it cannot drift. A library that is too old to have a function this client calls is caught at load, by name, rather than at the call.

The columnar surface

Every column of a result is readable as one borrowed buffer over the engine's own memory. java.nio rather than MemorySegment, because a Java 17 caller can name a LongBuffer and the JNI provider can hand back the same thing without copying. Read-only, and in native byte order set explicitly, because asByteBuffer() returns a big-endian view and a wrong byte order is a wrong number rather than a failure.

Summing one integer column of a hundred thousand rows, M-series laptop, JDK 25:

HowPer row
r.longs(0) and a loop over the buffer0.45 ns
the same a chunk at a time4.1 ns
for (Row row : r) row.getLong(0)45 ns
r.stream().mapToLong(...)67 ns

A row at a time is a boundary crossing a cell, and a hundred of those cost about what one borrowed buffer costs. Both surfaces are here because both are the right answer to a different question.

Native access

From JDK 24 a downcall out of a module that was not granted native access warns, and the warning is on a path to becoming an error. The jar carries Enable-Native-Access: ALL-UNNAMED for the class path case, the module path case passes --enable-native-access=dev.zudb.ffm, and FfmProvider checks Module::isNativeAccessEnabled before the first downcall so that a caller who has neither gets an exception naming the flag rather than a JVM warning on stderr three frames from any of our code.

Why 25 and not 22

FFM was finalised in 22 and 22 has been out of support since September 2024. Targeting an unsupported release only moves the problem, so the provider is release 25 and the JNI provider will carry 17 through 21.

Tests

111, green against libzu built from the engine at its own HEAD. The API module tests need no library at all. The FFM tests find one through -Dzu.library, ZU_LIBRARY or a sibling engine checkout, and skip rather than fail when there is none, so a checkout with no engine beside it is still green.

The engine has no DDL, so nothing here writes a schema and the tests live on the expression and projection surface: RETURN, UNWIND, parameters, lists, records, and all seven temporal kinds out and back. range() does not exist either, so the bulk-row tests build a literal list.

CI builds the API artifact on 17, 21, 25 and 26, runs the whole suite on Linux and macOS on 25 and 26, once plainly and once with -ea -esa so that the bounds checks a MemorySegment does are actually on, and builds and runs the benchmarks because a benchmark is code nothing else compiles.

What changed in the README

The opening example used CREATE NODE TABLE and conn.loadCsv. The engine has no DDL and the ABI has no CSV call, so neither would run. The example is now a MATCH over a graph something else built, with a paragraph saying plainly what does and does not work today. The jextract claim is gone for the reason above.

Follow-ups, not in this PR

zu_version in the engine returns a hard-coded 0.0.1 rather than the crate version, so it will drift the first time the crate version moves. Worth a one-line fix in tamnd/zu.

A zu_abi_version() runtime symbol would let a binding with no C compile step ask instead of write the constant down.

Next here: the JNI provider for 17 through 21, the native artifacts, GraalVM reachability metadata, and Maven Central publishing.

Milestone: DX4, tamnd/zu#170.

Two artifacts. dev.zudb:zudb is the API, compiled to release 17, with no
native code in it and no FFM type anywhere in its public surface, so it is
the thing a caller on any supported JDK compiles against. dev.zudb:zudb-ffm
is the provider, compiled to release 25, and it is the only place that
calls libzu. A ServiceLoader picks between providers at run time and
application code never names one.
The downcall handles are written by hand against zu.h rather than generated
with jextract. The ABI here is around seventy functions with a stable shape,
and the decisions worth making are the ones a generator does not make: which
calls are Linker.Option.critical because they are short pure accessors, where
the out-parameter space comes from so that a query does not allocate, and how
a zu_error becomes a typed Java exception exactly once.
Three things in the binding are worth reading before the rest:
Scratch is a per-thread off-heap block that every call writes its
out-parameters into, reset with a bump pointer at the top of each call rather
than allocated per call. An Arena.ofConfined per call is a malloc and a free
on a path that is otherwise a handful of instructions, and no binding method
calls another, so there is nothing for a reset to invalidate.
zu_config is filled in by this client rather than by zu_config_init. The
struct is versioned by a struct_size the caller sets, and asking a newer
library to initialise a buffer sized by our header is asking it to write past
the end of it.
A column comes back as a read-only java.nio buffer over the engine's own
memory, in native byte order, because asByteBuffer hands back a big-endian
view and a wrong byte order is a wrong number rather than a failure. java.nio
rather than MemorySegment so that a Java 17 caller can name the type and so
that the JNI provider can return the same thing.
Native access is granted rather than assumed. The jar carries
Enable-Native-Access: ALL-UNNAMED for the class path case, the module path
case passes --enable-native-access=dev.zudb.ffm, and the provider checks
Module::isNativeAccessEnabled before the first downcall so that a caller who
has neither gets an exception naming the flag instead of a JVM warning three
frames from any of our code.
The FFM artifact targets 25 rather than the 22 that finalised the API,
because 22 has been out of support since September 2024.
111 tests, green against libzu built from the engine at HEAD. The suite skips
rather than fails when there is no library to find, so a checkout with no
engine beside it is still green.
The benchmarks say what the columnar surface is for. Summing one integer
column of a hundred thousand rows costs 0.45 ns a row through r.longs(0),
4.1 ns a row a chunk at a time, 45 ns a row through the Row iterator and
67 ns a row through the Stream. A row at a time is a boundary crossing a
cell, and a hundred of those cost about what one borrowed buffer costs.
CI builds the API artifact on 17, 21, 25 and 26, and runs the whole suite
against the engine at its own HEAD on Linux and macOS, once plainly and once
with assertions on everywhere. One step checks that the ABI version written
down in Zu.ABI_VERSION is the one the engine's zu.h declares, because
ZU_ABI_VERSION is a header macro rather than a symbol and a binding with no C
compile step has nowhere to read it from.
The README no longer opens with a CREATE NODE TABLE the engine cannot run.
@tamnd
tamnd merged commit 0dd94bc into mainAug 20, 2026
9 checks passed
@tamnd
tamnd deleted the java-core branch August 20, 2026 01:06
tamnd added a commit that referenced this pull request Aug 22, 2026
A binding holds native memory and the process that finds out later is
the user's. The suite here cannot see that: a test that closes nothing
and asserts on a message passes, and what it left behind is somebody
else's problem an hour into a run.
So the allocator is asked instead. A driver in the tck opens and closes
every handle this client hands out, failures beside successes, and
scripts/leaks.sh runs it with LeakSanitizer ahead of the JVM and reads
the report for blocks the engine allocated and nobody gave back.
The narrow question is the whole trick. A JVM does not free at exit, on
purpose, so pointing a leak checker at one that does nothing at all
reports about a megabyte in several thousand allocations and none of it
is anything a caller can act on. What is answerable is whether any
unfreed block came out of libzu, and a leak record carries the stack it
was allocated from, so it is answerable by reading frame #1.
Frame #1 rather than any frame, because of what the JNI row turned up.
Asking for a jmethodID allocates a JVM-side table entry the JVM never
frees, and the stack for it runs through the shim because the shim is
what asked. Any-frame matching called seven of those ours and they are
not: at #1 they are os::malloc in libjvm. A block the shim really did
allocate has the shim at #1 and is still caught. The count of records
let through is printed rather than dropped quietly.
The gate runs first and has to fail. A report with no libzu in it looks
the same whether nothing leaked, the sanitizer was never loaded, the
library was never called, or the driver died early, and three of those
four are green for the wrong reason. So the driver is run once with
ZU_LEAK_GATE=1, which drops a database, a connection, a statement, a
result, an appender and a frame on the floor, and the script stops if
that comes back clean.
On server3, both providers: the gate leaks 57 records naming
zu_execute, zu_appender_open and zu_database_open, and the clean run is
0 of 545 on Panama and 0 of 390 on JNI, against a JVM whose own report
is a megabyte either way. The full reactor is green beside it, 199 tck
cases and 12 Arrow.
Linux only. LeakSanitizer does not exist on macOS, and what covers the
same ground there is the lifecycle half of the misuse suite, which
counts open file descriptors either side of a few hundred failures and
needs no allocator to agree with it.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@tamnd
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Java SDK v1: the API, the Panama provider, tests and benchmarks - #1

Merged
tamnd merged 1 commit into
mainfrom
java-core
Aug 20, 2026
Merged

Java SDK v1: the API, the Panama provider, tests and benchmarks#1
tamnd merged 1 commit into
mainfrom
java-core

Conversation

@tamnd

Copy link
Copy Markdown
Owner

This is the first code in the repository. Two artifacts, one of which is what a caller compiles against and the other of which is the only thing that calls libzu.

dev.zudb:zudb is the API. Release 17, no native code, no FFM type anywhere in its public surface, so it is the artifact a caller on any supported JDK depends on. dev.zudb:zudb-ffm is the Panama provider, release 25. A ServiceLoader picks between providers at run time and application code never names one.

Why the bindings are hand written

The seed README said jextract would generate them. It does not, and it should not. The ABI here is around seventy functions with a shape that does not move, and the decisions worth making are the ones a generator does not make.

Which calls get Linker.Option.critical(false), for one. zu_result_rows, zu_value_type, zu_error_status and six others are pure accessors that read a field and return, and paying a thread state transition for each of them is most of what they cost.

Where the out-parameter space comes from, for another. Every one of these calls needs somewhere to put a pointer or a length, and an Arena.ofConfined() per call is a malloc and a free on a path that is otherwise a handful of instructions. Scratch is a per-thread off-heap block that a call resets with a bump pointer at the top and writes into. No binding method calls another and every out-parameter is read before the call returns, so there is nothing a reset can invalidate.

And how a zu_error becomes a Java exception, which happens in exactly one place. The subclass comes from the GQLSTATUS class rather than from the message: 42 is a ZuSyntaxException, 22 is a ZuDataException, 25 and 40 are ZuTransactionException, and so on down. The exception carries the whole diagnostic, so a caller reads code(), condition(), position() and retryable() rather than parsing prose. The error is freed in a finally, and there is a test that provokes a thousand failures and then keeps using the connection.

Two things about the ABI that are worth knowing

zu_config is filled in by this client rather than by zu_config_init. The struct is versioned by a struct_size the caller sets, and asking a newer library to initialise a buffer sized by our header is asking it to write past the end of it.

ZU_ABI_VERSION is a header macro rather than a symbol, so a binding with no C compile step has nowhere to read it from and has to write it down. Zu.ABI_VERSION is that copy, and there is a CI step that checks it against the engine's zu.h so it cannot drift. A library that is too old to have a function this client calls is caught at load, by name, rather than at the call.

The columnar surface

Every column of a result is readable as one borrowed buffer over the engine's own memory. java.nio rather than MemorySegment, because a Java 17 caller can name a LongBuffer and the JNI provider can hand back the same thing without copying. Read-only, and in native byte order set explicitly, because asByteBuffer() returns a big-endian view and a wrong byte order is a wrong number rather than a failure.

Summing one integer column of a hundred thousand rows, M-series laptop, JDK 25:

HowPer row
r.longs(0) and a loop over the buffer0.45 ns
the same a chunk at a time4.1 ns
for (Row row : r) row.getLong(0)45 ns
r.stream().mapToLong(...)67 ns

A row at a time is a boundary crossing a cell, and a hundred of those cost about what one borrowed buffer costs. Both surfaces are here because both are the right answer to a different question.

Native access

From JDK 24 a downcall out of a module that was not granted native access warns, and the warning is on a path to becoming an error. The jar carries Enable-Native-Access: ALL-UNNAMED for the class path case, the module path case passes --enable-native-access=dev.zudb.ffm, and FfmProvider checks Module::isNativeAccessEnabled before the first downcall so that a caller who has neither gets an exception naming the flag rather than a JVM warning on stderr three frames from any of our code.

Why 25 and not 22

FFM was finalised in 22 and 22 has been out of support since September 2024. Targeting an unsupported release only moves the problem, so the provider is release 25 and the JNI provider will carry 17 through 21.

Tests

111, green against libzu built from the engine at its own HEAD. The API module tests need no library at all. The FFM tests find one through -Dzu.library, ZU_LIBRARY or a sibling engine checkout, and skip rather than fail when there is none, so a checkout with no engine beside it is still green.

The engine has no DDL, so nothing here writes a schema and the tests live on the expression and projection surface: RETURN, UNWIND, parameters, lists, records, and all seven temporal kinds out and back. range() does not exist either, so the bulk-row tests build a literal list.

CI builds the API artifact on 17, 21, 25 and 26, runs the whole suite on Linux and macOS on 25 and 26, once plainly and once with -ea -esa so that the bounds checks a MemorySegment does are actually on, and builds and runs the benchmarks because a benchmark is code nothing else compiles.

What changed in the README

The opening example used CREATE NODE TABLE and conn.loadCsv. The engine has no DDL and the ABI has no CSV call, so neither would run. The example is now a MATCH over a graph something else built, with a paragraph saying plainly what does and does not work today. The jextract claim is gone for the reason above.

Follow-ups, not in this PR

zu_version in the engine returns a hard-coded 0.0.1 rather than the crate version, so it will drift the first time the crate version moves. Worth a one-line fix in tamnd/zu.

A zu_abi_version() runtime symbol would let a binding with no C compile step ask instead of write the constant down.

Next here: the JNI provider for 17 through 21, the native artifacts, GraalVM reachability metadata, and Maven Central publishing.

Milestone: DX4, tamnd/zu#170.

Two artifacts. dev.zudb:zudb is the API, compiled to release 17, with no
native code in it and no FFM type anywhere in its public surface, so it is
the thing a caller on any supported JDK compiles against. dev.zudb:zudb-ffm
is the provider, compiled to release 25, and it is the only place that
calls libzu. A ServiceLoader picks between providers at run time and
application code never names one.
The downcall handles are written by hand against zu.h rather than generated
with jextract. The ABI here is around seventy functions with a stable shape,
and the decisions worth making are the ones a generator does not make: which
calls are Linker.Option.critical because they are short pure accessors, where
the out-parameter space comes from so that a query does not allocate, and how
a zu_error becomes a typed Java exception exactly once.
Three things in the binding are worth reading before the rest:
Scratch is a per-thread off-heap block that every call writes its
out-parameters into, reset with a bump pointer at the top of each call rather
than allocated per call. An Arena.ofConfined per call is a malloc and a free
on a path that is otherwise a handful of instructions, and no binding method
calls another, so there is nothing for a reset to invalidate.
zu_config is filled in by this client rather than by zu_config_init. The
struct is versioned by a struct_size the caller sets, and asking a newer
library to initialise a buffer sized by our header is asking it to write past
the end of it.
A column comes back as a read-only java.nio buffer over the engine's own
memory, in native byte order, because asByteBuffer hands back a big-endian
view and a wrong byte order is a wrong number rather than a failure. java.nio
rather than MemorySegment so that a Java 17 caller can name the type and so
that the JNI provider can return the same thing.
Native access is granted rather than assumed. The jar carries
Enable-Native-Access: ALL-UNNAMED for the class path case, the module path
case passes --enable-native-access=dev.zudb.ffm, and the provider checks
Module::isNativeAccessEnabled before the first downcall so that a caller who
has neither gets an exception naming the flag instead of a JVM warning three
frames from any of our code.
The FFM artifact targets 25 rather than the 22 that finalised the API,
because 22 has been out of support since September 2024.
111 tests, green against libzu built from the engine at HEAD. The suite skips
rather than fails when there is no library to find, so a checkout with no
engine beside it is still green.
The benchmarks say what the columnar surface is for. Summing one integer
column of a hundred thousand rows costs 0.45 ns a row through r.longs(0),
4.1 ns a row a chunk at a time, 45 ns a row through the Row iterator and
67 ns a row through the Stream. A row at a time is a boundary crossing a
cell, and a hundred of those cost about what one borrowed buffer costs.
CI builds the API artifact on 17, 21, 25 and 26, and runs the whole suite
against the engine at its own HEAD on Linux and macOS, once plainly and once
with assertions on everywhere. One step checks that the ABI version written
down in Zu.ABI_VERSION is the one the engine's zu.h declares, because
ZU_ABI_VERSION is a header macro rather than a symbol and a binding with no C
compile step has nowhere to read it from.
The README no longer opens with a CREATE NODE TABLE the engine cannot run.
@tamnd
tamnd merged commit 0dd94bc into mainAug 20, 2026
9 checks passed
@tamnd
tamnd deleted the java-core branch August 20, 2026 01:06
tamnd added a commit that referenced this pull request Aug 22, 2026
A binding holds native memory and the process that finds out later is
the user's. The suite here cannot see that: a test that closes nothing
and asserts on a message passes, and what it left behind is somebody
else's problem an hour into a run.
So the allocator is asked instead. A driver in the tck opens and closes
every handle this client hands out, failures beside successes, and
scripts/leaks.sh runs it with LeakSanitizer ahead of the JVM and reads
the report for blocks the engine allocated and nobody gave back.
The narrow question is the whole trick. A JVM does not free at exit, on
purpose, so pointing a leak checker at one that does nothing at all
reports about a megabyte in several thousand allocations and none of it
is anything a caller can act on. What is answerable is whether any
unfreed block came out of libzu, and a leak record carries the stack it
was allocated from, so it is answerable by reading frame #1.
Frame #1 rather than any frame, because of what the JNI row turned up.
Asking for a jmethodID allocates a JVM-side table entry the JVM never
frees, and the stack for it runs through the shim because the shim is
what asked. Any-frame matching called seven of those ours and they are
not: at #1 they are os::malloc in libjvm. A block the shim really did
allocate has the shim at #1 and is still caught. The count of records
let through is printed rather than dropped quietly.
The gate runs first and has to fail. A report with no libzu in it looks
the same whether nothing leaked, the sanitizer was never loaded, the
library was never called, or the driver died early, and three of those
four are green for the wrong reason. So the driver is run once with
ZU_LEAK_GATE=1, which drops a database, a connection, a statement, a
result, an appender and a frame on the floor, and the script stops if
that comes back clean.
On server3, both providers: the gate leaks 57 records naming
zu_execute, zu_appender_open and zu_database_open, and the clean run is
0 of 545 on Panama and 0 of 390 on JNI, against a JVM whose own report
is a megabyte either way. The full reactor is green beside it, 199 tck
cases and 12 Arrow.
Linux only. LeakSanitizer does not exist on macOS, and what covers the
same ground there is the lifecycle half of the misuse suite, which
counts open file descriptors either side of a few hundred failures and
needs no allocator to agree with it.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@tamnd