GraalVM native image
A StoatFlow application compiles to a GraalVM native image — a standalone binary with fast start-up, a small footprint, and (with the right GC) steady-state CPU and tail latency on par with the JVM. The opt-in is one line, because the framework already ships the native-image metadata for everything it touches; the only per-app work is the metadata for your own serde value classes.
This is the most advanced part of the build. Set up a working JVM build first (Project setup); for most deployments the JVM container image is the simpler, better-trodden path. Reach for native when cold-start time or per-instance memory is the binding constraint.
One-line opt-in
A StoatFlow app doesn't start from a blank GraalVM configuration. The framework bundles its native-image metadata, discovered automatically by classpath. Turning native image on applies GraalVM's build plugin, the curated --initialize-at-run-time flags RocksDB and the Kafka SASL client need, and the nativeDockerBuild task:
// build.gradle.kts
stoatflow {
mainClass.set("com.example.MainKt")
nativeImage {
enabled.set(true)
gc.set("G1") // Oracle GraalVM; omit for Serial GC (stateless only)
}
}
<!-- pom.xml — native config lives in <properties>; activate with -Pstoatflow-native -->
<properties>
<stoatflow.mainClass>com.example.Main</stoatflow.mainClass>
<stoatflow.nativeImage.gc>G1</stoatflow.nativeImage.gc>
</properties>
Build it
The containerized build is the recommended path for any image you'll actually run. It compiles the native binary inside Docker from your prebuilt fat JAR, against a generic, repo-independent Dockerfile.native the build stages for you — so it pins the build-host glibc, wires the GC and the reproducible SOURCE_DATE_EPOCH, stages the heap entrypoint, and applies the runtimeTarget:
./gradlew nativeDockerBuild
# → <imageName>:native + <imageName>:<version>-native
# Quick local smoke-compile (host GraalVM, no Docker):
./gradlew nativeCompile
mvn package # build the fat JAR first
mvn stoatflow:native-docker-build
# → <imageName>:native + <imageName>:<version>-native
# Quick local smoke-compile (host GraalVM, no Docker):
mvn -Pstoatflow-native package
The containerized build stages a build context (build/stoatflow-native/ for Gradle) holding the prebuilt app.jar, the shared native-image.args argfile, the heap entrypoint, and a pgo/ directory, then runs native-image @native-image.args … against the bundled Dockerfile.native. Detected platform: linux/arm64 on Apple Silicon, otherwise linux/amd64.
The pipeline, stage by stage:
Dockerfile.native builds on a pinned Oracle GraalVM image (native-image:25-ol9, Oracle Linux 9 / glibc 2.34) and runs on a distroless cc-debian12 base (glibc 2.36), so the binary doesn't crash-loop on a GLIBC_2.xx not found mismatch — and you get G1 + PGO, which Community Edition lacks. A raw host nativeCompile is best treated as a does-it-compile smoke check.Runtime target
runtimeTarget selects the final image stage:
runtime(default) — acc-debian12:debugdistroless base with a busybox shell so the container-aware heap entrypoint can size-Xmxfrom the container memory limit, reservingSTOATFLOW_ROCKSDB_MBfor RocksDB off-heap (recommended).runtime-lean— a minimalcc-debian12distroless base, no shell, no entrypoint: SubstrateVM sizes the heap itself. Smallest possible image.
stoatflow { nativeImage { runtimeTarget.set("runtime-lean") } }
<properties>
<stoatflow.nativeImage.runtimeTarget>runtime-lean</stoatflow.nativeImage.runtimeTarget>
</properties>
The garbage collector matters most
The single most important native-image decision is the GC — far more than the foreign-function path or anything exotic.
- GraalVM Community Edition ships only Serial GC for native image. Serial is fine for a stateless transform, but it cannot carry an allocation-heavy stateful (RocksDB) workload — under load a stateful app on Serial falls behind the input and never catches up (we measured a 112× end-to-end latency blowup).
- Oracle GraalVM (free for production under the GFTC licence, and what the containerized build already uses) unlocks G1 and PGO. Switching Serial → G1 — one flag — takes a stateful app straight back to JVM parity.
The rule: stateless → Serial is acceptable; stateful → use G1. Set gc to G1 and the containerized build passes it through to native-image.
-Xmx, and the container entrypoint hands the JVM most of the container — so a tiny-heap stateless app can use more RSS than on the JVM. For those, pin a tight -Xmx (an env override, no rebuild) to recover the win. Stateful apps, whose live footprint is larger, already land below the JVM's total memory. See Tuning for how GC interacts with commit cadence.The RocksDB backend: AUTO (FFM ⇄ JNI)
StoatFlow talks to RocksDB's C API through two interchangeable backends, and the faster one inverts between runtimes:
- On the JVM, FFM wins. The JIT intrinsifies its
critical(false)downcalls down to nearly a direct C call. - Under native image, JNI wins. SubstrateVM has no JIT, so that intrinsification never fires and a plain JNI call ends up cheaper (~8% less CPU, better P99 in our measurements).
So the default backend is AUTO: FFM on the JVM, JNI under native image. You don't configure anything — each runtime is routed to its faster path automatically, and the brittle FFM-under-native machinery is off the default path entirely.
Where reachability metadata lives
GraalVM's closed-world analysis needs metadata for everything reached via reflection, JNI, resources, and FFM foreign downcalls. StoatFlow ships this metadata inside the module JARs — not in per-app config files:
| Module | Metadata path | Covers |
|---|---|---|
stoatflow-core | META-INF/native-image/io.stoatflow/core/ | Kafka serde reflection, the Kafka client, RocksDB (both the JNI bindings and the FFM foreign downcalls), JCTools, zstd |
stoatflow-runtime | META-INF/native-image/io.stoatflow/runtime/ | the YAML config binding, Logback, Micrometer/HdrHistogram, and the rest of the runtime stack |
Any app that depends on stoatflow-runtime (and transitively stoatflow-core) inherits the correct metadata automatically through GraalVM's classpath discovery. You copy or generate nothing for the StoatFlow surface — only for your own types, next.
Custom serde metadata
The closed-world compiler still needs to know about your types — the value classes you serialize/deserialize — because anything reflective, JNI, or FFM must be declared up front, or it fails the first time that code path runs (never at build time).
The reliable way to generate it is the GraalVM tracing agent: run your app (or its tests) on the JVM with the agent attached, exercise every code path, and it writes the metadata. Drop the result under your module's resources so GraalVM discovers it by classpath:
# Run your app/tests on the JVM with the tracing agent, driving real traffic:
java -agentlib:native-image-agent=config-output-dir=src/main/resources/META-INF/native-image/com.example/word-count \
--enable-preview --enable-native-access=ALL-UNNAMED -jar build/libs/word-count-all.jar
MissingReflectionRegistrationError only surfaces when its code path first executes — so exercise your serdes, punctuators, joins, and error paths under the agent.Snares & gotchas
The discoveries that cost an afternoon each:
- Avro + Schema Registry is the worst offender. Confluent's stack instantiates classes by name from its own config defaults (e.g.
NullContextNameStrategy), Jackson-deserializes the registry's REST entities, and native image disables URL protocols by default — so the schema fetch fails until you add--enable-url-protocols=http,https. The tracing agent catches the config-default classes in one pass; it's the surer route for anything Avro. reachability-metadata.jsonshadows the legacyreflection-config.json. When both are present the newer format wins and silently drops the old file's registrations. Keep your metadata in the newerreachability-metadata.json; don't run the two side by side.- PGO profiles only dump on clean shutdown. A streaming app runs forever, so extracting a
.iproftakes plumbing — the bundled native entrypoint forwardsSIGTERMto the child process sokill -TERM 1drains and dumps the profile. (See the deep-dive.)
Profile-Guided Optimisation (PGO)
PGO is the CPU lever — a release-time, two-pass build (compile an instrumented binary, run it under representative load to collect a .iprof, rebuild with the profile). It's purely a CPU optimisation (latency/throughput are already at parity under G1) and trims the residual AOT-vs-JIT gap by another ~13–17%. It's Oracle-GraalVM-only and adds real build time, so treat it as a release step, not a per-change one. The build context includes a pgo/ directory where the collected profile is staged. The full recipe — instrumenting, extracting the profile from a long-running server, and rebuilding — is in the engineering deep-dive.
What the build passes to native-image
The static flags come from a single bundled native-image.args (identical for Gradle, Maven, and the containerized build); --gc and PGO are layered on dynamically. You write none of these by hand:
-H:+JNI,-H:+ReportExceptionStackTraces,-H:+AddAllCharsets, the Kotlin resource includes,--enable-preview,--no-fallback.--initialize-at-run-timefororg.rocksdb,org.rocksdb.RocksDB,org.rocksdb.util.Environment, the FFM RocksDB bindings, and the Kafka SASL client authenticator — these load native libraries or read runtime state and must initialize when the binary starts, not when it's compiled.
If your own code (or a dependency) needs run-time initialization, add it via additionalInitializeAtRunTime (Gradle) / extend the native-maven-plugin <buildArgs> (Maven); drop a curated entry with removeInitializeAtRunTime. The full enumeration is in the Gradle plugin reference. The typical trigger is a build-time error: Classes that should be initialized at run time got initialized during image building — add the named class and rebuild.
Verify it runs
After building, run the image and check the same health and metrics endpoints as the JVM runtime:
docker run -d --name sf-native \
-e KAFKA_BOOTSTRAP_SERVERS=host.docker.internal:9092 \
-e STOATFLOW_LICENSE_KEY="key/...your key..." \
-p 8080:8080 \
my-app:native
curl -s localhost:8080/health/ready
curl -s localhost:8080/info
If the process exits at startup with Classes that should be initialized at run time got initialized during image building or a MissingReflectionRegistrationError, the fix is metadata: add the class to additionalInitializeAtRunTime, or register the reflective access via the tracing agent, then rebuild.
What's covered — and what isn't yet
Covered today (benchmarked under load): String, Protobuf, and Avro + Schema Registry serdes; stateless and stateful (RocksDB) topologies; exactly-once. With Oracle GraalVM + G1 + PGO, the apps we measured run within −4% to +10% CPU of the JVM, latency equal-or-better everywhere, and memory below the JVM on the stateful apps.
Not yet mapped — these pull in reflection/JNI surface that still needs native metadata:
- Kafka client security — SASL (SCRAM, GSSAPI/Kerberos), OAuth bearer, and mTLS.
- Compression codecs beyond zstd — snappy, lz4, gzip.
If you hit a MissingReflectionRegistrationError on one of these, the tracing agent is your first move — and let us know, so we can fold the metadata into the framework.
Knob reference
| Capability | Gradle (stoatflow { nativeImage { … } }) | Maven (<properties> / build) |
|---|---|---|
| Enable | enabled.set(true) | activate -Pstoatflow-native |
| Garbage collector | gc.set("G1") | <stoatflow.nativeImage.gc>G1</…> |
| Runtime image stage | runtimeTarget.set("runtime-lean") | <stoatflow.nativeImage.runtimeTarget>…</…> |
Add --initialize-at-run-time | additionalInitializeAtRunTime.add("…") | extend the native-maven-plugin <buildArgs> |
Drop a curated --initialize-at-run-time | removeInitializeAtRunTime.add("…") | <stoatflow.nativeImage.removeInitializeAtRunTime>…</…> (CSV) |
Further reading
- The engineering deep-dive — G1, PGO, why JNI beats FFM under AOT, and the full benchmark matrix.
- Benchmarks — the numbers behind the claims here.
- Gradle plugin reference · Maven reference — every native knob, task, and default.
- Docker — the JVM container image (Jib), the simpler default deployment target.
- Kubernetes and Probes — deploying the image and wiring liveness/readiness.
Docker images
Build a container image for your StoatFlow app with the StoatFlow build conventions (Jib) — base image, JVM flags, reproducible builds, container-aware heap sizing, ports and env — on Gradle or Maven.
Deploying and operating
The deployment model for operators — one active instance and why two corrupt state, the two HA tiers (fast restart and opt-in hot standby), and a map of the operating pages.