GraalVM native image

Compile your StoatFlow app to a GraalVM native binary — a one-line opt-in. The framework ships its own reflection/JNI/FFM metadata; you choose the GC, optionally PGO, and add metadata only for your own serde value classes.

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)
    }
}

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

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:

Why build in Docker? A native binary links the build host's glibc and needs that version (or newer) at runtime. The bundled 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.
Native compilation is resource-hungry: budget 8 GB+ RAM and 15–20 minutes. The containerized build needs only Docker — no local GraalVM install.

Runtime target

runtimeTarget selects the final image stage:

  • runtime (default) — a cc-debian12:debug distroless base with a busybox shell so the container-aware heap entrypoint can size -Xmx from the container memory limit, reserving STOATFLOW_ROCKSDB_MB for RocksDB off-heap (recommended).
  • runtime-lean — a minimal cc-debian12 distroless base, no shell, no entrypoint: SubstrateVM sizes the heap itself. Smallest possible image.
stoatflow { nativeImage { runtimeTarget.set("runtime-lean") } }

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.

Memory is tunable, not free. Under Serial GC a native image is tiny. Under G1 the heap reservation is sized from -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:

ModuleMetadata pathCovers
stoatflow-coreMETA-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-runtimeMETA-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
Drive every path before you trust the binary. Closed-world compilation moves failures from build time to first-record time. A 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.json shadows the legacy reflection-config.json. When both are present the newer format wins and silently drops the old file's registrations. Keep your metadata in the newer reachability-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 .iprof takes plumbing — the bundled native entrypoint forwards SIGTERM to the child process so kill -TERM 1 drains 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-time for org.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

CapabilityGradle (stoatflow { nativeImage { … } })Maven (<properties> / build)
Enableenabled.set(true)activate -Pstoatflow-native
Garbage collectorgc.set("G1")<stoatflow.nativeImage.gc>G1</…>
Runtime image stageruntimeTarget.set("runtime-lean")<stoatflow.nativeImage.runtimeTarget>…</…>
Add --initialize-at-run-timeadditionalInitializeAtRunTime.add("…")extend the native-maven-plugin <buildArgs>
Drop a curated --initialize-at-run-timeremoveInitializeAtRunTime.add("…")<stoatflow.nativeImage.removeInitializeAtRunTime>…</…> (CSV)

Further reading