Add graalvm-native-images: Boot 4.1 + GraalVM CE for JDK 25, AOT processing,
the tracing agent, and a real reflection-collision trap Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01EQNA6DJ9VgCtW6zhCE8Xud
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
# 03 — A real reflection failure, its fix, and the trap inside the fix
|
||||
|
||||
[← Previous: 02 — building the image](02-building-the-image.md) | [Back to README →](../README.md) | [Next: 04 — honest limits →](04-honest-limits.md)
|
||||
|
||||
## The trap
|
||||
|
||||
[`ReportController`](../src/main/java/com/ankurm/graalvmdemo/report/ReportController.java) builds
|
||||
a class name at request time from a query parameter and instantiates it reflectively --
|
||||
[`PlainTextReport`](../src/main/java/com/ankurm/graalvmdemo/report/PlainTextReport.java) or
|
||||
[`JsonReport`](../src/main/java/com/ankurm/graalvmdemo/report/JsonReport.java) -- the same shape
|
||||
as a plugin loaded by name from configuration, or a strategy resolved from a database column.
|
||||
Spring's AOT engine registers reflection metadata for everything it can see statically -- every
|
||||
`@RestController`, every bean -- but it has no way to know that a `format` query parameter can
|
||||
resolve to one of these two classes; nothing in the source connects them at build time.
|
||||
|
||||
On the plain JVM, this works exactly as written. On the first native build (no hints added),
|
||||
hitting the endpoint fails outright:
|
||||
|
||||
```
|
||||
REFLECTION-FAILED class=com.ankurm.graalvmdemo.report.PlainTextReport exception=java.lang.ClassNotFoundException message=com.ankurm.graalvmdemo.report.PlainTextReport
|
||||
```
|
||||
|
||||
Full transcript: [docs/output/03-native-startup-broken.txt](output/03-native-startup-broken.txt).
|
||||
Note it is `ClassNotFoundException`, not merely a reflection-access error -- GraalVM's closed-world
|
||||
build excluded the class from the binary entirely, because nothing reachable from `main()` proved
|
||||
it was needed.
|
||||
|
||||
## The fix: the tracing agent, run against the plain jar
|
||||
|
||||
`native-image-agent`, attached to a normal JVM run, watches real reflective calls and writes the
|
||||
same unified `reachability-metadata.json` format the AOT engine produces:
|
||||
|
||||
```
|
||||
java -agentlib:native-image-agent=config-output-dir=agent-output -jar target/app.jar
|
||||
curl "localhost:8083/report?format=plain"
|
||||
curl "localhost:8083/report?format=json"
|
||||
```
|
||||
|
||||
Full transcript: [docs/output/04-tracing-agent.txt](output/04-tracing-agent.txt). The agent adds
|
||||
real overhead to startup while attached -- 5.552 s here versus the 3.472 s baseline -- which is
|
||||
why it belongs in a one-off exploratory run or a dedicated test suite, not in production.
|
||||
|
||||
## The trap inside the fix
|
||||
|
||||
Copying the agent's generated entries for `PlainTextReport` and `JsonReport` into
|
||||
`src/main/resources/META-INF/native-image/com.ankurm/graalvm-native-images/reachability-metadata.json`
|
||||
-- the project's own Maven coordinates, which felt like the obviously correct place -- silently
|
||||
did nothing. Rebuilding and re-testing still failed with the identical `ClassNotFoundException`.
|
||||
The cause, confirmed by diffing the two files byte-for-byte: Spring's own AOT engine writes its
|
||||
generated metadata to that *exact same path*, and whichever copy the build processes last wins.
|
||||
`target/classes/META-INF/native-image/com.ankurm/graalvm-native-images/reachability-metadata.json`
|
||||
turned out to be Spring's 188 KB generated file, identical to the one in `target/spring-aot/`,
|
||||
with the two hand-added entries nowhere in it.
|
||||
|
||||
The fix is to put hand-written hints under a namespace that cannot collide with a real Maven
|
||||
coordinate -- this project uses
|
||||
[`src/main/resources/META-INF/native-image/com.ankurm.graalvmdemo/manual-hints/reachability-metadata.json`](../src/main/resources/META-INF/native-image/com.ankurm.graalvmdemo/manual-hints/reachability-metadata.json).
|
||||
GraalVM merges every `META-INF/native-image/**/reachability-metadata.json` it finds on the
|
||||
classpath regardless of what the intermediate folder names are -- they exist purely so different
|
||||
jars' metadata files don't collide with each other, which is exactly the property this fix needs.
|
||||
With that in place, the same two endpoints succeed:
|
||||
|
||||
```
|
||||
OK via reflection on com.ankurm.graalvmdemo.report.PlainTextReport: REPORT: quarterly numbers
|
||||
OK via reflection on com.ankurm.graalvmdemo.report.JsonReport: {"report":"quarterly numbers"}
|
||||
```
|
||||
|
||||
Full transcript: [docs/output/05-native-startup-fixed.txt](output/05-native-startup-fixed.txt).
|
||||
Startup and memory barely moved (0.104 s, ~105.7 MB RSS versus 0.091 s and ~105.5 MB without the
|
||||
fix) -- two extra reflectively-constructible classes are noise against the rest of the image.
|
||||
|
||||
- Never hand-write hints at your own project's exact `groupId`/`artifactId` path -- verify with
|
||||
`diff` against `target/spring-aot/main/resources/...` if a hint you added seems to have no
|
||||
effect.
|
||||
- `@RegisterReflectionForBinding` on the calling code is the alternative to a hand-written or
|
||||
agent-generated JSON file, and does not have this collision risk since it feeds Spring's own
|
||||
AOT-generated file rather than a second one.
|
||||
Reference in New Issue
Block a user