Conversation
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.
Uh oh!
There was an error while loading. Please reload this page.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:zudbis 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-ffmis the Panama provider, release 25. AServiceLoaderpicks 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_statusand 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.Scratchis 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_errorbecomes a Java exception, which happens in exactly one place. The subclass comes from the GQLSTATUS class rather than from the message: 42 is aZuSyntaxException, 22 is aZuDataException, 25 and 40 areZuTransactionException, and so on down. The exception carries the whole diagnostic, so a caller readscode(),condition(),position()andretryable()rather than parsing prose. The error is freed in afinally, 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_configis filled in by this client rather than byzu_config_init. The struct is versioned by astruct_sizethe 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_VERSIONis 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_VERSIONis that copy, and there is a CI step that checks it against the engine'szu.hso 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.niorather thanMemorySegment, because a Java 17 caller can name aLongBufferand the JNI provider can hand back the same thing without copying. Read-only, and in native byte order set explicitly, becauseasByteBuffer()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:
r.longs(0)and a loop over the bufferfor (Row row : r) row.getLong(0)r.stream().mapToLong(...)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-UNNAMEDfor the class path case, the module path case passes--enable-native-access=dev.zudb.ffm, andFfmProviderchecksModule::isNativeAccessEnabledbefore 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_LIBRARYor 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 -esaso that the bounds checks aMemorySegmentdoes 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 TABLEandconn.loadCsv. The engine has no DDL and the ABI has no CSV call, so neither would run. The example is now aMATCHover 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_versionin the engine returns a hard-coded0.0.1rather than the crate version, so it will drift the first time the crate version moves. Worth a one-line fix intamnd/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.