Skip to content

Commit 7407c75

Browse files
committed
The engine comes with the client
A Java client is Java and the engine is a shared library, so until now using this meant installing a libzu by hand and telling the JVM where it went. One more dependency now does it: zudb-native carries a build for every platform this client supports, Library.find looks for one on the class path, and a resource is copied to a temp file because no loader on any platform can map a resource. Eight artifacts come out of one module and one staged directory, so they cannot disagree about what is in them. The one with no classifier holds all seven platforms and is what a program developed on a laptop and deployed to a cluster wants, and the only one that survives being shaded. The seven classified ones hold one platform each, which is what a container image that knows what it runs on wants. Alpine is a platform rather than a smaller Linux: a shared object built against glibc does not load on musl, and says so in a message about an interpreter rather than about a database. Library.flavour picks between the two by looking for musl's own loader, which is the one path the ABI fixes rather than a distribution. Two things a user meets go with it. On the module path nothing requires this artifact, because there is no code in it to require, so a jar that is on the path is never resolved and its library is invisible; the search notices it is on a module path and names --add-modules dev.zudb.natives rather than leaving a user to work out why a class path run worked. And the derived automatic module name would be zudb.native, which is not a legal module name because native is a keyword, so every jar carries Automatic-Module-Name in its manifest. The search now also carries the places it ruled out, and Zu.source says which of the four answered, because a path on its own does not explain why the wrong engine is loaded and a property somebody set three shells ago is exactly the thing that explains it. Nothing in the test suite could check the claim this makes, since the suite is told where the library is so that it tests the binding rather than the search. So CI checks it the way a user meets it, on Linux and macOS: a class path, no property, no environment variable, and a statement that has to answer.
1 parent 3877bb3 commit 7407c75

10 files changed

Lines changed: 531 additions & 10 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,84 @@ jobs:
107107

108108
- run: java -jar zudb-bench/target/benchmarks.jar -f 1 -wi 1 -i 1 -r 1s -w 1s
109109

110+
# The claim the zudb-native artifact makes is that a user who added a
111+
# dependency and installed nothing has an engine. Nothing in the test
112+
# suite can check that, because the suite is told where the library is
113+
# so that it tests the binding rather than the search. So it is
114+
# checked here, once, the way a user meets it: a classpath, no
115+
# property, no environment variable, and a statement.
116+
natives:
117+
strategy:
118+
fail-fast: false
119+
matrix:
120+
os: [ubuntu-latest, macos-latest]
121+
runs-on: ${{ matrix.os }}
122+
steps:
123+
- uses: actions/checkout@v5
124+
125+
- uses: actions/checkout@v5
126+
with:
127+
repository: tamnd/zu
128+
path: engine
129+
130+
- uses: actions/setup-java@v5
131+
with:
132+
distribution: temurin
133+
java-version: "25"
134+
cache: maven
135+
136+
- uses: Swatinem/rust-cache@v2
137+
with:
138+
workspaces: engine
139+
140+
- name: Build libzu
141+
working-directory: engine
142+
run: cargo build --release -p zu-capi
143+
144+
# One platform rather than seven, because this runner can only
145+
# build the one it is, so the rule that every platform is staged
146+
# is stood down here and holds where it matters, in the release.
147+
- name: Stage the one platform this runner is
148+
run: |
149+
set -eu
150+
case "$RUNNER_OS" in
151+
Linux) flavour=linux-amd64; library=libzu.so ;;
152+
macOS) flavour=darwin-arm64; library=libzu.dylib ;;
153+
*) echo "no row for $RUNNER_OS"; exit 1 ;;
154+
esac
155+
mkdir -p "zudb-native/lib/$flavour"
156+
cp "engine/target/release/$library" "zudb-native/lib/$flavour/$library"
157+
158+
- run: mvn $MAVEN_ARGS -Pnatives -DskipTests -Denforcer.skip=true package
159+
160+
- name: A classpath, and nothing else
161+
run: |
162+
set -eu
163+
mkdir -p "$RUNNER_TEMP/user"
164+
cat > "$RUNNER_TEMP/user/Main.java" <<'EOF'
165+
import dev.zudb.Connection;
166+
import dev.zudb.Result;
167+
import dev.zudb.Zu;
168+
169+
public class Main {
170+
public static void main(String[] args) {
171+
System.out.println("found " + Zu.library() + " through " + Zu.source());
172+
try (Connection conn = Connection.memory();
173+
Result r = conn.query("RETURN 1 AS one")) {
174+
if (r.row(0).getLong(0) != 1L) {
175+
throw new AssertionError("the engine answered something else");
176+
}
177+
}
178+
System.out.println("the engine came out of the jar and answered");
179+
}
180+
}
181+
EOF
182+
cp=$(ls zudb/target/zudb-*.jar zudb-ffm/target/zudb-ffm-*.jar \
183+
zudb-native/target/zudb-native-*.jar | grep -v sources | tr '\n' ':')
184+
javac -cp "$cp" -d "$RUNNER_TEMP/user" "$RUNNER_TEMP/user/Main.java"
185+
env -u ZU_LIBRARY java --enable-native-access=ALL-UNNAMED \
186+
-cp "$cp$RUNNER_TEMP/user" Main
187+
110188
# What Maven Central will run over the artifacts, run here instead so
111189
# that a release is not the first time anyone sees it.
112190
javadoc:

‎.gitignore‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
.DS_Store
22
target/
33

4+
# What scripts/stage-natives.sh downloads. A build of the engine is a
5+
# thing a release fetches, not a thing a git history carries.
6+
zudb-native/lib/

‎README.md‎

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,37 @@ Config config = Config.of(Map.of("threads", "1", "memory_limit", "1073741824"));
185185

186186
The keys and the parsing belong to the engine rather than to this client, so a key added to the engine since this client was built works anyway, and a key that never existed is refused with the typo named. A suffix such as `MB` is deliberately not parsed anywhere: its two readings differ by 4.9%, and the place to decide which one a user meant is where the user typed it.
187187

188+
## The engine, without installing one
189+
190+
The client is Java and the engine is a shared library, so something has to put a `libzu` on the machine. Adding one more dependency is that something:
191+
192+
```xml
193+
<dependency>
194+
<groupId>dev.zudb</groupId>
195+
<artifactId>zudb-native</artifactId>
196+
<version>${zu.version}</version>
197+
<scope>runtime</scope>
198+
</dependency>
199+
```
200+
201+
That artifact carries a build for every platform this client supports and is about twenty megabytes. It is the right one for a program developed on a laptop and deployed to a cluster, and the only one that survives being shaded into an uber-jar. A container image knows exactly what it runs on, so it can name a platform and take about three megabytes instead:
202+
203+
| Classifier | What it holds |
204+
|---|---|
205+
| `linux-amd64` | glibc, x86-64 |
206+
| `linux-arm64` | glibc, aarch64 |
207+
| `linux-amd64-musl` | musl, x86-64 |
208+
| `linux-arm64-musl` | musl, aarch64 |
209+
| `darwin-amd64` | macOS, Intel |
210+
| `darwin-arm64` | macOS, Apple silicon |
211+
| `windows-amd64` | Windows, x86-64 |
212+
213+
Alpine is a separate row rather than a smaller Linux, because a shared object built against glibc does not load on musl and the message it fails with talks about an interpreter rather than about a database. Which of the two a JVM gets is decided by looking for musl's own loader on disk, which is the one path the ABI fixes rather than a distribution.
214+
215+
The library inside the jar is a resource, and no loader on any platform can map one of those, so it is copied to a temp file the first time anything needs it and the copy is what gets loaded. That happens once per JVM.
216+
217+
On the module path the artifact needs `--add-modules dev.zudb.natives`. Nothing `requires` it, since there is no code in it to require, and a jar nothing requires is a jar that is never resolved and whose resources are therefore invisible. The search says so itself when it comes up empty on a module path, so the failure names the flag rather than leaving a user to work out why the same classpath run worked.
218+
188219
## How it binds
189220

190221
The Foreign Function and Memory API is the primary path. The downcall handles are written by hand against `zu.h` rather than generated with `jextract`, because the C ABI here is around seventy functions with a stable shape, and a hand-written layer is where the interesting decisions live: which calls are `Linker.Option.critical` because they are short pure accessors, where the out-parameter scratch space comes from so that a query does not allocate, and how a `zu_error` becomes a typed Java exception exactly once. There is no native code in this repository beyond `libzu` itself.
@@ -196,7 +227,7 @@ An SDK that requires a recent JDK in 2026 excludes a large part of the enterpris
196227
| `dev.zudb:zudb` | Java 17 | the API, no native code, no FFM types in the public surface |
197228
| `dev.zudb:zudb-ffm` | Java 25 | the FFM provider, selected automatically |
198229
| `dev.zudb:zudb-jni` | Java 17 | the fallback provider |
199-
| `dev.zudb:zudb-native-{platform}` | | the `libzu` binaries |
230+
| `dev.zudb:zudb-native` | | the `libzu` binaries, all platforms or one by classifier |
200231

201232
A `ServiceLoader` picks the provider at run time and application code never names one. The FFM artifact targets Java 25 rather than the Java 22 that finalised the API, because 22 has been out of support since September 2024 and shipping against an unsupported release only moves the problem. CI runs 17, 21, 25, and 26.
202233

@@ -226,7 +257,16 @@ The engine has no DDL yet, so there is no `CREATE NODE TABLE` and no statement i
226257
mvn test -Dzu.library=/path/to/libzu.dylib
227258
```
228259

229-
The provider looks at `-Dzu.library`, then `ZU_LIBRARY`, then the platform library path. The tests skip rather than fail when no `libzu` is reachable, so a checkout with no engine build beside it is still green.
260+
The library is looked for in four places, in order: `-Dzu.library`, then `ZU_LIBRARY`, then a `zudb-native` artifact on the class path, then the platform's own search. A named path is first because a bisect and a bug report both start by pointing this at a build, and the platform's search is last because it is the one that can pick up a library nobody in the process chose. `Zu.library()` and `Zu.source()` say which file was loaded and which of the four it came from, and a failure to bind lists what was ruled out on the way. The tests skip rather than fail when no `libzu` is reachable, so a checkout with no engine build beside it is still green.
261+
262+
The `zudb-native` module is not built unless it is asked for, because what it packages is downloaded rather than compiled:
263+
264+
```sh
265+
scripts/stage-natives.sh v0.11.0
266+
mvn -Pnatives package -DskipTests
267+
```
268+
269+
The argument is a release tag of the engine, which is fetched with `gh`, or a directory that already holds the archives.
230270

231271
The benchmarks are JMH and are not published:
232272

‎pom.xml‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@
8484
<maven.source.plugin.version>3.4.0</maven.source.plugin.version>
8585
<maven.javadoc.plugin.version>3.12.0</maven.javadoc.plugin.version>
8686
<maven.shade.plugin.version>3.6.2</maven.shade.plugin.version>
87+
<maven.enforcer.plugin.version>3.6.2</maven.enforcer.plugin.version>
8788
<maven.gpg.plugin.version>3.2.8</maven.gpg.plugin.version>
8889
<central.publishing.plugin.version>0.11.0</central.publishing.plugin.version>
8990
</properties>
@@ -155,6 +156,17 @@
155156
</build>
156157

157158
<profiles>
159+
<!-- The libraries, which are downloaded rather than compiled, so an
160+
ordinary build has no business needing them and does not build
161+
this module. scripts/stage-natives.sh fills zudb-native/lib
162+
first; CI passes this after it has run. -->
163+
<profile>
164+
<id>natives</id>
165+
<modules>
166+
<module>zudb-native</module>
167+
</modules>
168+
</profile>
169+
158170
<!-- Sources, javadoc and signatures, which Maven Central requires
159171
and a local build has no use for. -->
160172
<profile>

‎scripts/stage-natives.sh‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
#!/usr/bin/env bash
2+
# Lay the seven libzu builds out where zudb-native packages them from.
3+
#
4+
# Usage: scripts/stage-natives.sh <source>
5+
#
6+
# scripts/stage-natives.sh v0.11.0 a tag of tamnd/zu
7+
# scripts/stage-natives.sh ../zu/dist a directory of built prefixes
8+
#
9+
# The engine names its targets the way Rust does and this client names
10+
# its platforms the way Go does, because that is what every other client
11+
# of this engine names its artifacts after. The table below is the only
12+
# place the two spellings meet, so a target added to the engine is one
13+
# row here and nothing else.
14+
#
15+
# What is copied is the shared library and only the shared library. The
16+
# archive also carries the static library, the header, the CLI, the
17+
# pkg-config file and the CMake package, and none of those is anything a
18+
# JVM can use: a jar that held them would be a jar that is four times
19+
# the size for a file nobody opens.
20+
set -euo pipefail
21+
22+
source="${1:?usage: stage-natives.sh <tag or directory>}"
23+
here="$(cd "$(dirname "$0")/.." && pwd)"
24+
out="$here/zudb-native/lib"
25+
26+
# rust target, go platform, library file name
27+
rows="
28+
x86_64-unknown-linux-gnu linux-amd64 libzu.so
29+
aarch64-unknown-linux-gnu linux-arm64 libzu.so
30+
x86_64-unknown-linux-musl linux-amd64-musl libzu.so
31+
aarch64-unknown-linux-musl linux-arm64-musl libzu.so
32+
x86_64-apple-darwin darwin-amd64 libzu.dylib
33+
aarch64-apple-darwin darwin-arm64 libzu.dylib
34+
x86_64-pc-windows-msvc windows-amd64 zu.dll
35+
"
36+
37+
work=""
38+
if [ -d "$source" ]; then
39+
prefixes="$source"
40+
else
41+
# A tag, which means the release archives. Downloaded once into a
42+
# directory of this run's own, so that a second run of the script
43+
# cannot half-unpack over the first.
44+
work="$(mktemp -d)"
45+
trap 'rm -rf "$work"' EXIT
46+
prefixes="$work"
47+
echo "downloading libzu $source from tamnd/zu"
48+
for target in $(echo "$rows" | awk 'NF {print $1}'); do
49+
archive="libzu-$target.tar.zst"
50+
gh release download "$source" --repo tamnd/zu --pattern "$archive" --dir "$work"
51+
# The documented fallback as well as the first choice, because
52+
# tar learned --zstd in 1.31 and RHEL 8 ships 1.30.
53+
if tar --zstd -tf "$work/$archive" >/dev/null 2>&1; then
54+
tar --zstd -xf "$work/$archive" -C "$work"
55+
else
56+
zstd -dc "$work/$archive" | tar -xf - -C "$work"
57+
fi
58+
done
59+
fi
60+
61+
rm -rf "$out"
62+
echo "$rows" | while read -r target platform library; do
63+
[ -n "$target" ] || continue
64+
from="$prefixes/libzu-$target"
65+
# The library lives in bin/ on Windows, where a DLL is a thing that
66+
# runs, and in lib/ everywhere else.
67+
if [ -f "$from/bin/$library" ]; then
68+
from="$from/bin/$library"
69+
else
70+
from="$from/lib/$library"
71+
fi
72+
if [ ! -f "$from" ]; then
73+
echo "no $library for $target under $prefixes" >&2
74+
exit 1
75+
fi
76+
mkdir -p "$out/$platform"
77+
cp "$from" "$out/$platform/$library"
78+
# A library a loader has to be able to map, whatever the transport
79+
# did to the mode on the way here.
80+
chmod 0755 "$out/$platform/$library"
81+
echo "$platform $(du -h "$out/$platform/$library" | cut -f1)"
82+
done

0 commit comments

Comments
 (0)