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
78 lines
4.6 KiB
Markdown
78 lines
4.6 KiB
Markdown
# 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.
|