diff --git a/README.md b/README.md index a5e0228..1909ac6 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,9 @@ files. | [`core-events/`](core-events) | [Spring Application Events: @EventListener, @TransactionalEventListener and Async Events](https://ankurm.com/spring-application-events-eventlistener-transactionaleventlistener-async/) | publishing and consuming events, listener ordering, conditional listeners, `AFTER_COMMIT` behaviour proven with a rollback, and async listeners | | [`multi-datasource/`](multi-datasource) | [Multiple DataSources in Spring Boot 4 with Spring Data JPA](https://ankurm.com/multiple-datasources-spring-boot-4-spring-data-jpa/) | two H2 databases each with its own `DataSource`, Flyway, `EntityManagerFactory` and transaction manager, six ways of getting the wiring wrong, plain vs named `@Transactional`, and `ChainedTransactionManager` under a failing commit | | [`i18n/`](i18n) | [Internationalization (i18n) in Spring Boot 4: MessageSource, LocaleResolver and Localized ProblemDetail](https://ankurm.com/spring-boot-4-internationalization-messagesource-localeresolver-problemdetail/) | English, Marathi and Hindi message bundles, `Accept-Language` vs a cookie resolver, apostrophes and Devanagari digits in `MessageFormat`, the JVM default locale as a hidden fallback, localized validation messages and `ProblemDetail` titles | +| [`aot-cache/`](aot-cache) | [Faster Spring Boot Startup with the JDK AOT Cache (JEP 514/515): Benchmarks vs CDS and Native](https://ankurm.com/spring-boot-jdk-aot-cache-jep-514-515-startup-benchmarks/) | the JDK 25 AOT cache trained two ways, against a plain JVM, AppCDS, Spring's own AOT output and a GraalVM native image (ten interleaved rounds each), first-request latency, 24 cases where the cache silently stops applying (jar, timestamp, JVM build, ZGC, compact headers), `-XX:AOTMode=on`, Docker layer arithmetic | -`core-di`, `core-beans`, `custom-starter`, `core-events`, `multi-datasource` and `i18n` are the exception: they have **no `docs/` folder**. Their deeper material lives in +`core-di`, `core-beans`, `custom-starter`, `core-events`, `multi-datasource`, `i18n` and `aot-cache` are the exception: they have **no `docs/` folder**. Their deeper material lives in collapsible sections inside the articles themselves, and their captured output sits in a top-level `output/` directory instead of `docs/output/`. diff --git a/aot-cache/.gitignore b/aot-cache/.gitignore new file mode 100644 index 0000000..ba655b7 --- /dev/null +++ b/aot-cache/.gitignore @@ -0,0 +1,6 @@ +target/ +*.aot +*.aotconf +*.jsa +work/ +work-run.log diff --git a/aot-cache/README.md b/aot-cache/README.md new file mode 100644 index 0000000..02c9486 --- /dev/null +++ b/aot-cache/README.md @@ -0,0 +1,70 @@ +# aot-cache: the JDK 25 AOT cache on a Spring Boot 4.1 service + +Companion project for **[Faster Spring Boot Startup with the JDK AOT Cache (JEP 514/515): Benchmarks vs CDS and Native](https://ankurm.com/spring-boot-jdk-aot-cache-jep-514-515-startup-benchmarks/)** +on [ankurm.com](https://ankurm.com). Everything the article quotes is a file in [`output/`](output), regenerated by +[`run.sh`](run.sh). The explanation lives in the article; this directory holds the code and the raw transcripts. +There is no `docs/` folder on purpose: the deeper material sits in collapsible sections of the article itself. + +## The service + +A small Spring Boot 4.1.1 web application, built to have the ingredients of a typical service and no more: +Spring MVC and Jackson (`POST /orders` with a JSON body), Bean Validation (a record with `@Email`, `@Pattern`, +`@Min`/`@Max`), the actuator health endpoint, and an in-memory store. No database. Sources are under +[`src/main/java/com/ankurm/aotcache/`](src/main/java/com/ankurm/aotcache). + +## Versions + +| | | +|---|---| +| Spring Boot | 4.1.1 (Spring Framework 7.0.9, Tomcat 11.0.24, Jackson 3.1.5, Hibernate Validator 9.1.3) | +| JDK for the article | Temurin 25.0.4.1 (LTS) | +| JDK for the comparison | Temurin 27+35 | +| GraalVM (native image) | Community Edition 25.0.2+10.1 | +| Machine | 2 vCPU, 8 GB, Linux | + +## What is here + +| Script | What it does | Output | +|---|---|---| +| [`scripts/prepare.sh`](scripts/prepare.sh) | Builds the plain jar, a jar with Spring's own AOT output, a jar after a one-line change; extracts them | [`00-prepare.txt`](output/00-prepare.txt), [`00-environment.txt`](output/00-environment.txt) | +| [`scripts/native.sh`](scripts/native.sh) | Compiles the GraalVM native image | [`03-native-build.txt`](output/03-native-build.txt) | +| [`scripts/train.sh`](scripts/train.sh) | The training runs: AppCDS, AOT cache (context-only and with traffic, with and without Spring AOT, on JDK 27, under ZGC) | [`01-training.txt`](output/01-training.txt) | +| [`scripts/startup.sh`](scripts/startup.sh) | 12 configurations x 10 interleaved rounds: time to ready, Boot's own figure, first request, resident memory | [`02-startup.txt`](output/02-startup.txt) | +| [`scripts/mismatch.sh`](scripts/mismatch.sh) | 24 ways the cache and the run can disagree, and what the JVM says and does | [`04-mismatch.txt`](output/04-mismatch.txt) | +| [`scripts/layers.sh`](scripts/layers.sh) | Docker layer arithmetic without Docker: which layers change on a one-line edit, sizes, gzipped sizes | [`05-layers.txt`](output/05-layers.txt) | +| [`scripts/sample.sh`](scripts/sample.sh) | A transcript of the service's endpoints | [`06-sample-endpoints.txt`](output/06-sample-endpoints.txt) | +| [`docker/Dockerfile`](docker/Dockerfile) | A layered image with the cache trained in the final stage (**not built here**: no Docker daemon) | | + +## Headline result + +Median of ten runs, milliseconds from starting the process to the first `200` from `GET /ping` +([`output/02-startup.txt`](output/02-startup.txt)): + +| Configuration | Ready | First `POST /orders` | +|---|---|---| +| Plain JVM, extracted jar | 3,920 | 173 | +| AppCDS | 2,583 | 164 | +| AOT cache, context-only training | 2,022 | 143 | +| AOT cache, training with traffic | 1,949 | 68 | +| AOT cache + Spring AOT, training with traffic | 1,436 | 89 | +| GraalVM native image | 133 | 5.3 | + +## Running it + +Needs JDK 25, JDK 27, a GraalVM for JDK 25 and Maven; `scripts/env.sh` shows where they are looked up and each can be +overridden with `JDK25`, `JDK27` and `GRAALVM`. The measurement scripts assume Linux and `curl`, and need the machine to +themselves. + +```console +cd aot-cache +./run.sh # about 35 minutes; the native build is 5 of them +ROUNDS=3 ./run.sh # a quick pass +``` + +## Caveats + +* One small service on one 2-CPU virtual machine. The ratios are what to take away, not the milliseconds. +* No database and no JPA, so the service is smaller than most real ones; a larger classpath usually gains more from the cache. +* JEP 515 (method profiles) is part of the cache on JDK 25 and cannot be switched off separately, so its own contribution was not isolated. +* The Docker image was not built; the layer figures come from extracting the jar's layers and comparing files. +* `openjdk.org/jeps` returned HTTP 403 to the tooling used, so JEP claims come from running the JDKs and from secondary sources named in the article. diff --git a/aot-cache/docker/Dockerfile b/aot-cache/docker/Dockerfile new file mode 100644 index 0000000..e784877 --- /dev/null +++ b/aot-cache/docker/Dockerfile @@ -0,0 +1,21 @@ +# NOT BUILT OR RUN while writing the article: the sandbox has no Docker daemon. The commands inside it are the ones +# scripts/train.sh and scripts/mismatch.sh ran (extract, AOTCacheOutput, AOTCache); the layer arithmetic is in +# output/05-layers.txt. Treat the file as a starting point and measure it in your own registry. +# +# Build: mvn -DskipTests package && docker build -f docker/Dockerfile -t aot-cache . + +FROM eclipse-temurin:25-jdk AS extract +WORKDIR /build +COPY target/app.jar app.jar +RUN java -Djarmode=tools -jar app.jar extract --layers --destination extracted + +# The final image is also where the cache is trained: the cache records the exact JVM build and the exact +# jar files (size and modification time), so it must be created by the JVM that will use it, after the last COPY. +FROM eclipse-temurin:25-jre +WORKDIR /application +COPY --from=extract /build/extracted/dependencies/ ./ +COPY --from=extract /build/extracted/spring-boot-loader/ ./ +COPY --from=extract /build/extracted/snapshot-dependencies/ ./ +COPY --from=extract /build/extracted/application/ ./ +RUN java -XX:AOTCacheOutput=app.aot -Dspring.context.exit=onRefresh -jar app.jar +ENTRYPOINT ["java", "-XX:AOTCache=app.aot", "-jar", "app.jar"] diff --git a/aot-cache/output/00-environment.txt b/aot-cache/output/00-environment.txt new file mode 100644 index 0000000..cdee92b --- /dev/null +++ b/aot-cache/output/00-environment.txt @@ -0,0 +1,24 @@ +# machine +cpus: 2 memory: 8032 MB kernel: 6.18.44-fc-v37 + +# JDK 25 (the JVM the article is about) +openjdk version "25.0.4.1" 2026-08-18 LTS +OpenJDK Runtime Environment Temurin-25.0.4.1+1 (build 25.0.4.1+1-LTS) +OpenJDK 64-Bit Server VM Temurin-25.0.4.1+1 (build 25.0.4.1+1-LTS, mixed mode, sharing) + +# JDK 27 (the comparison) +openjdk version "27" 2026-09-15 +OpenJDK Runtime Environment Temurin-27+35 (build 27+35) +OpenJDK 64-Bit Server VM Temurin-27+35 (build 27+35, mixed mode, sharing) + +# GraalVM (native image) +native-image 25.0.2 2026-01-20 +GraalVM Runtime Environment GraalVM CE 25.0.2+10.1 (build 25.0.2+10-jvmci-b01) + +# libraries +hibernate-validator-9.1.3.Final +jackson-databind-3.1.5 +spring-boot-4.1.1 +spring-core-7.0.9 +spring-webmvc-7.0.9 +tomcat-embed-core-11.0.24 diff --git a/aot-cache/output/00-prepare.txt b/aot-cache/output/00-prepare.txt new file mode 100644 index 0000000..5b75037 --- /dev/null +++ b/aot-cache/output/00-prepare.txt @@ -0,0 +1,14 @@ +== build: plain jar (mvn package) +== build: jar with Spring AOT output (mvn -Pnative package; this does NOT compile a native image) +initializers in plain jar: 0 +initializers in Spring AOT jar: 1 +== extract both jars (java -Djarmode=tools -jar app.jar extract) +/home/claude/spring-boot-demo/aot-cache/work/ext: +app.jar +lib + +/home/claude/spring-boot-demo/aot-cache/work/ext-springaot: +app.jar +lib +== build: the same service after a one-line change (the unit price in OrderService), for the mismatch and layer checks +edited: 20: BigDecimal unit = BigDecimal.valueOf(5_99, 2); diff --git a/aot-cache/output/01-training.txt b/aot-cache/output/01-training.txt new file mode 100644 index 0000000..72e902e --- /dev/null +++ b/aot-cache/output/01-training.txt @@ -0,0 +1,17 @@ +# training runs (JDK 25.0.4.1 unless noted); wall time includes JVM start, context start and cache creation +AppCDS (ArchiveClassesAtExit) 4741 ms appcds.jsa (38 MB) +AOT cache, fat jar, context-only 8219 ms fat.aot (56 MB) +AOT cache, context-only training 7916 ms refresh.aot (59 MB) +AOT cache, traffic training 10157 ms traffic.aot (66 MB) +AOT cache + Spring AOT, context-only 7164 ms springaot.aot (57 MB) +AOT cache + Spring AOT, traffic 8733 ms springaot-traffic.aot (64 MB) +AOT cache on JDK 27, context-only 9292 ms refresh27.aot (62 MB) +AOT cache on JDK 27, trained under ZGC 9426 ms zgc27.aot (62 MB) + +# what the JVM said at warning level while writing the context-only AOT cache (count, then the distinct kinds) +warning lines: 122 + 96 [warning][aot] Skipping : Unsupported location + 4 [warning][aot] Skipping : Unlinked class not supported by AOTClassLinking + 4 [warning][aot] Skipping : Failed verification + 3 [warning][aot] Skipping : JFR event class + 2 [warning][aot] Skipping : Not in loaded state diff --git a/aot-cache/output/02-startup.txt b/aot-cache/output/02-startup.txt new file mode 100644 index 0000000..e8749cd --- /dev/null +++ b/aot-cache/output/02-startup.txt @@ -0,0 +1,138 @@ +# 12 configurations x 10 rounds; one unrecorded warm-up round first (so cache files are in the page cache) + +== medians of 10 runs (min..max in brackets), milliseconds; rss in MB +configuration ready (exec->200) Boot 'Started in' 1st POST /orders next 50 (median) RSS +1 jvm-fat-jar 4630.5 [4281..4931] 3683.5 [3455..3954] 204.0 [166.6..230.7] 4.5 194.0 +2 jvm-extracted 3919.5 [3486..4018] 3242.0 [2890..3400] 173.3 [146.6..191.7] 4.8 189.0 +3 jvm+spring-aot 3162.0 [2991..3500] 2552.0 [2419..2907] 201.8 [165.3..254.3] 4.5 183.0 +4 appcds 2582.5 [2436..2730] 2181.0 [2022..2330] 164.3 [133.9..170.2] 4.8 195.0 +5 aot-cache fat-jar 3247.0 [3096..3507] 2553.5 [2432..2787] 156.6 [144.9..186.6] 4.7 235.0 +6 aot-cache context-only 2021.5 [1948..2253] 1568.0 [1495..1785] 142.8 [116.1..160.0] 4.8 218.0 +7 aot-cache traffic 1948.5 [1777..2142] 1623.5 [1406..1775] 67.7 [47.5..101.8] 4.5 198.5 +8 aot-cache+spring-aot 1588.5 [1482..1674] 1120.5 [1030..1218] 146.6 [131.4..163.9] 4.5 188.0 +9 aot-cache+spring-aot traffic 1436.0 [1372..1651] 1069.0 [1015..1280] 89.3 [65.9..113.0] 4.2 184.0 +10 jdk27 plain 3779.5 [3595..4205] 3122.5 [2919..3408] 164.8 [139.9..176.8] 4.6 234.0 +11 jdk27 aot-cache 1983.0 [1840..2181] 1571.5 [1444..1763] 138.2 [117.9..191.0] 4.2 195.5 +12 native image 133.0 [127..171] 101.5 [90..133] 5.3 [4.6..6.0] 1.0 120.0 + +== every run +1 jvm-fat-jar 1 ready=4407 started=3460 first=201.9 second=6.4 next50=3.2 rss=186 +2 jvm-extracted 1 ready=3486 started=2890 first=187.6 second=7.4 next50=4.0 rss=189 +3 jvm+spring-aot 1 ready=3111 started=2495 first=203.0 second=8.9 next50=4.5 rss=189 +4 appcds 1 ready=2535 started=2133 first=166.6 second=12.8 next50=4.8 rss=190 +5 aot-cache fat-jar 1 ready=3096 started=2432 first=157.2 second=11.4 next50=4.3 rss=234 +6 aot-cache context-only 1 ready=1948 started=1555 first=133.0 second=6.0 next50=3.4 rss=211 +7 aot-cache traffic 1 ready=1984 started=1642 first=47.5 second=5.5 next50=4.5 rss=200 +8 aot-cache+spring-aot 1 ready=1482 started=1030 first=153.1 second=7.2 next50=4.4 rss=189 +9 aot-cache+spring-aot traffic 1 ready=1372 started=1057 first=73.2 second=11.0 next50=3.5 rss=184 +10 jdk27 plain 1 ready=3595 started=2919 first=165.6 second=13.0 next50=4.5 rss=224 +11 jdk27 aot-cache 1 ready=1890 started=1517 first=135.2 second=7.1 next50=3.4 rss=195 +12 native image 1 ready=127 started=90 first=4.6 second=0.9 next50=0.7 rss=120 +1 jvm-fat-jar 2 ready=4495 started=3692 first=166.6 second=20.1 next50=4.2 rss=195 +2 jvm-extracted 2 ready=3725 started=3117 first=173.6 second=7.5 next50=4.1 rss=191 +3 jvm+spring-aot 2 ready=3121 started=2522 first=199.1 second=10.0 next50=4.1 rss=188 +4 appcds 2 ready=2496 started=2104 first=166.9 second=8.3 next50=4.2 rss=200 +5 aot-cache fat-jar 2 ready=3250 started=2579 first=186.6 second=17.7 next50=4.6 rss=239 +6 aot-cache context-only 2 ready=2029 started=1568 first=140.9 second=16.2 next50=3.5 rss=218 +7 aot-cache traffic 2 ready=1840 started=1506 first=76.5 second=9.3 next50=3.4 rss=200 +8 aot-cache+spring-aot 2 ready=1550 started=1070 first=131.4 second=14.7 next50=5.0 rss=188 +9 aot-cache+spring-aot traffic 2 ready=1444 started=1070 first=78.5 second=5.8 next50=4.2 rss=185 +10 jdk27 plain 2 ready=3767 started=3121 first=164.1 second=8.4 next50=3.9 rss=219 +11 jdk27 aot-cache 2 ready=1844 started=1444 first=124.8 second=6.0 next50=4.2 rss=196 +12 native image 2 ready=171 started=133 first=5.6 second=1.4 next50=1.0 rss=120 +1 jvm-fat-jar 3 ready=4281 started=3455 first=188.9 second=7.0 next50=4.1 rss=187 +2 jvm-extracted 3 ready=3944 started=3315 first=163.8 second=8.1 next50=4.0 rss=191 +3 jvm+spring-aot 3 ready=2991 started=2419 first=254.3 second=9.6 next50=4.4 rss=177 +4 appcds 3 ready=2436 started=2049 first=170.2 second=7.7 next50=4.5 rss=198 +5 aot-cache fat-jar 3 ready=3127 started=2478 first=156.1 second=8.6 next50=4.4 rss=231 +6 aot-cache context-only 3 ready=2020 started=1601 first=149.9 second=12.2 next50=4.7 rss=219 +7 aot-cache traffic 3 ready=1777 started=1406 first=65.2 second=6.2 next50=4.2 rss=200 +8 aot-cache+spring-aot 3 ready=1547 started=1066 first=134.8 second=6.7 next50=5.5 rss=190 +9 aot-cache+spring-aot traffic 3 ready=1383 started=1015 first=84.9 second=6.3 next50=4.7 rss=184 +10 jdk27 plain 3 ready=3704 started=3067 first=139.9 second=7.2 next50=4.4 rss=237 +11 jdk27 aot-cache 3 ready=1840 started=1494 first=125.0 second=9.8 next50=4.1 rss=196 +12 native image 3 ready=157 started=131 first=5.6 second=1.2 next50=0.9 rss=120 +1 jvm-fat-jar 4 ready=4665 started=3675 first=190.9 second=7.0 next50=4.6 rss=196 +2 jvm-extracted 4 ready=3837 started=3188 first=191.7 second=7.7 next50=5.2 rss=190 +3 jvm+spring-aot 4 ready=3205 started=2568 first=165.3 second=12.6 next50=4.0 rss=177 +4 appcds 4 ready=2450 started=2022 first=168.7 second=11.6 next50=3.7 rss=193 +5 aot-cache fat-jar 4 ready=3244 started=2644 first=151.2 second=7.2 next50=4.9 rss=229 +6 aot-cache context-only 4 ready=2012 started=1495 first=126.3 second=11.3 next50=4.0 rss=205 +7 aot-cache traffic 4 ready=1899 started=1537 first=70.1 second=12.4 next50=5.5 rss=186 +8 aot-cache+spring-aot 4 ready=1644 started=1207 first=163.0 second=13.2 next50=4.5 rss=188 +9 aot-cache+spring-aot traffic 4 ready=1472 started=1144 first=65.9 second=10.6 next50=4.2 rss=184 +10 jdk27 plain 4 ready=3712 started=3060 first=176.8 second=7.3 next50=4.7 rss=236 +11 jdk27 aot-cache 4 ready=1988 started=1608 first=147.4 second=10.8 next50=4.0 rss=195 +12 native image 4 ready=127 started=94 first=5.0 second=1.1 next50=1.0 rss=120 +1 jvm-fat-jar 5 ready=4668 started=3654 first=217.1 second=7.7 next50=4.5 rss=203 +2 jvm-extracted 5 ready=4018 started=3400 first=180.4 second=10.5 next50=5.2 rss=196 +3 jvm+spring-aot 5 ready=3500 started=2907 first=177.4 second=12.6 next50=4.5 rss=186 +4 appcds 5 ready=2690 started=2267 first=133.9 second=9.2 next50=4.5 rss=197 +5 aot-cache fat-jar 5 ready=3169 started=2456 first=174.6 second=8.8 next50=5.1 rss=234 +6 aot-cache context-only 5 ready=2253 started=1785 first=144.7 second=9.3 next50=5.8 rss=218 +7 aot-cache traffic 5 ready=1939 started=1622 first=101.8 second=7.7 next50=4.5 rss=194 +8 aot-cache+spring-aot 5 ready=1591 started=1143 first=142.9 second=10.1 next50=4.3 rss=188 +9 aot-cache+spring-aot traffic 5 ready=1461 started=1126 first=96.8 second=6.1 next50=4.0 rss=184 +10 jdk27 plain 5 ready=3709 started=3007 first=174.4 second=8.6 next50=5.0 rss=230 +11 jdk27 aot-cache 5 ready=1987 started=1589 first=191.0 second=6.7 next50=4.5 rss=195 +12 native image 5 ready=136 started=96 first=6.0 second=1.0 next50=1.0 rss=119 +1 jvm-fat-jar 6 ready=4524 started=3575 first=196.3 second=8.5 next50=4.4 rss=195 +2 jvm-extracted 6 ready=3986 started=3300 first=150.2 second=10.1 next50=4.8 rss=176 +3 jvm+spring-aot 6 ready=3108 started=2560 first=217.2 second=13.3 next50=4.3 rss=177 +4 appcds 6 ready=2730 started=2330 first=152.3 second=7.5 next50=5.0 rss=197 +5 aot-cache fat-jar 6 ready=3399 started=2698 first=154.3 second=9.4 next50=5.2 rss=238 +6 aot-cache context-only 6 ready=2211 started=1697 first=151.7 second=14.4 next50=5.7 rss=218 +7 aot-cache traffic 6 ready=1872 started=1542 first=61.0 second=8.4 next50=5.2 rss=198 +8 aot-cache+spring-aot 6 ready=1624 started=1150 first=135.1 second=12.4 next50=5.7 rss=188 +9 aot-cache+spring-aot traffic 6 ready=1380 started=1051 first=107.5 second=11.7 next50=4.8 rss=185 +10 jdk27 plain 6 ready=4103 started=3408 first=168.7 second=7.7 next50=5.1 rss=249 +11 jdk27 aot-cache 6 ready=1961 started=1570 first=141.3 second=13.2 next50=5.0 rss=197 +12 native image 6 ready=131 started=105 first=5.0 second=2.7 next50=1.2 rss=119 +1 jvm-fat-jar 7 ready=4787 started=3913 first=224.4 second=10.0 next50=4.7 rss=197 +2 jvm-extracted 7 ready=3899 started=3237 first=165.2 second=8.8 next50=5.4 rss=189 +3 jvm+spring-aot 7 ready=3294 started=2623 first=172.8 second=8.7 next50=4.8 rss=184 +4 appcds 7 ready=2592 started=2172 first=162.1 second=9.6 next50=4.8 rss=205 +5 aot-cache fat-jar 7 ready=3347 started=2528 first=176.4 second=7.8 next50=4.8 rss=236 +6 aot-cache context-only 7 ready=1989 started=1567 first=116.1 second=7.3 next50=5.5 rss=218 +7 aot-cache traffic 7 ready=2111 started=1771 first=100.5 second=13.5 next50=4.5 rss=200 +8 aot-cache+spring-aot 7 ready=1597 started=1147 first=152.7 second=10.4 next50=3.9 rss=188 +9 aot-cache+spring-aot traffic 7 ready=1651 started=1280 first=85.4 second=9.1 next50=5.2 rss=185 +10 jdk27 plain 7 ready=3825 started=3124 first=166.7 second=8.9 next50=4.5 rss=240 +11 jdk27 aot-cache 7 ready=2033 started=1690 first=153.9 second=7.3 next50=5.5 rss=195 +12 native image 7 ready=132 started=100 first=4.9 second=1.3 next50=1.2 rss=120 +1 jvm-fat-jar 8 ready=4608 started=3729 first=211.0 second=8.0 next50=5.0 rss=192 +2 jvm-extracted 8 ready=4008 started=3345 first=180.4 second=8.2 next50=4.5 rss=181 +3 jvm+spring-aot 8 ready=3095 started=2479 first=214.1 second=8.6 next50=5.0 rss=177 +4 appcds 8 ready=2573 started=2199 first=161.4 second=8.6 next50=5.0 rss=192 +5 aot-cache fat-jar 8 ready=3177 started=2462 first=152.9 second=7.7 next50=4.6 rss=230 +6 aot-cache context-only 8 ready=1981 started=1528 first=160.0 second=37.6 next50=5.0 rss=218 +7 aot-cache traffic 8 ready=2142 started=1775 first=62.3 second=8.7 next50=4.1 rss=187 +8 aot-cache+spring-aot 8 ready=1586 started=1098 first=163.9 second=12.0 next50=5.3 rss=188 +9 aot-cache+spring-aot traffic 8 ready=1412 started=1047 first=93.3 second=9.0 next50=5.0 rss=184 +10 jdk27 plain 8 ready=3792 started=3125 first=152.4 second=11.5 next50=5.1 rss=235 +11 jdk27 aot-cache 8 ready=1979 started=1529 first=122.5 second=6.7 next50=3.8 rss=195 +12 native image 8 ready=166 started=117 first=5.3 second=1.5 next50=1.1 rss=119 +1 jvm-fat-jar 9 ready=4653 started=3760 first=230.7 second=10.4 next50=4.2 rss=193 +2 jvm-extracted 9 ready=3728 started=3060 first=173.0 second=8.9 next50=4.8 rss=182 +3 jvm+spring-aot 9 ready=3266 started=2544 first=200.6 second=10.7 next50=5.0 rss=182 +4 appcds 9 ready=2596 started=2244 first=136.5 second=7.9 next50=5.2 rss=192 +5 aot-cache fat-jar 9 ready=3507 started=2787 first=162.2 second=8.0 next50=4.8 rss=242 +6 aot-cache context-only 9 ready=2152 started=1720 first=155.7 second=12.1 next50=4.8 rss=219 +7 aot-cache traffic 9 ready=2088 started=1731 first=58.5 second=11.4 next50=4.0 rss=198 +8 aot-cache+spring-aot 9 ready=1674 started=1218 first=137.0 second=11.3 next50=4.2 rss=188 +9 aot-cache+spring-aot traffic 9 ready=1428 started=1068 first=113.0 second=10.8 next50=4.1 rss=184 +10 jdk27 plain 9 ready=4205 started=3396 first=157.9 second=10.0 next50=4.7 rss=224 +11 jdk27 aot-cache 9 ready=2181 started=1763 first=117.9 second=6.7 next50=4.0 rss=196 +12 native image 9 ready=133 started=98 first=5.4 second=1.4 next50=1.1 rss=120 +1 jvm-fat-jar 10 ready=4931 started=3954 first=206.1 second=8.8 next50=4.8 rss=193 +2 jvm-extracted 10 ready=3940 started=3247 first=146.6 second=12.8 next50=4.8 rss=185 +3 jvm+spring-aot 10 ready=3203 started=2587 first=206.7 second=8.9 next50=4.6 rss=187 +4 appcds 10 ready=2607 started=2190 first=169.4 second=7.8 next50=5.2 rss=193 +5 aot-cache fat-jar 10 ready=3495 started=2699 first=144.9 second=8.1 next50=3.7 rss=241 +6 aot-cache context-only 10 ready=2023 started=1568 first=134.5 second=32.5 next50=4.5 rss=218 +7 aot-cache traffic 10 ready=1958 started=1625 first=74.3 second=6.1 next50=5.1 rss=199 +8 aot-cache+spring-aot 10 ready=1556 started=1095 first=150.2 second=9.2 next50=4.3 rss=188 +9 aot-cache+spring-aot traffic 10 ready=1575 started=1213 first=107.0 second=6.5 next50=4.0 rss=184 +10 jdk27 plain 10 ready=4010 started=3356 first=156.0 second=7.2 next50=4.4 rss=233 +11 jdk27 aot-cache 10 ready=2016 started=1573 first=171.6 second=7.8 next50=4.5 rss=196 +12 native image 10 ready=133 started=103 first=5.4 second=1.2 next50=1.0 rss=120 diff --git a/aot-cache/output/03-native-build.txt b/aot-cache/output/03-native-build.txt new file mode 100644 index 0000000..13601a6 --- /dev/null +++ b/aot-cache/output/03-native-build.txt @@ -0,0 +1,9 @@ +# GraalVM: native-image 25.0.2 2026-01-20 +21,929 types, 31,331 fields, and 99,355 methods found reachable +36.0s (11.7% of total time) in 2209 GCs | Peak RSS: 4.70GB | CPU load: 1.86 +Finished generating 'aot-cache' in 5m 6s. +BUILD SUCCESS +Total time: 05:18 min +/home/claude/spring-boot-demo/aot-cache/work/aot-cache-native 99 MB +/home/claude/spring-boot-demo/aot-cache/work/app.jar 22 MB +build wall time: 320 s diff --git a/aot-cache/output/04-mismatch.txt b/aot-cache/output/04-mismatch.txt new file mode 100644 index 0000000..1e97ec6 --- /dev/null +++ b/aot-cache/output/04-mismatch.txt @@ -0,0 +1,152 @@ +# cache: /home/claude/spring-boot-demo/aot-cache/work/refresh.aot, trained on JDK 25.0.4.1, extracted layout, default GC (G1), 2 CPUs + +== control: the same JVM, the same jar, the same path + result: started, ready after 1987 ms (Started AotCacheApplication in 1.538 seconds) + log: (nothing at warning or error level) + +== the application jar was rebuilt after a one-line code change, default AOTMode=auto + result: started, ready after 4124 ms (Started AotCacheApplication in 3.453 seconds) + log: [warning][aot] This file is not the one used while building the AOT cache: 'app.jar', timestamp has changed, size has changed + log: [error ][aot] An error has occurred while processing the AOT cache. Run with -Xlog:aot for details. + log: [error ][aot] shared class paths mismatch (hint: enable -Xlog:class+path=info to diagnose the failure) + log: [error ][aot] Unable to map shared spaces + +== the same, with -XX:AOTMode=on + result: did not start (exit code 1) + log: [warning][aot] This file is not the one used while building the AOT cache: 'app.jar', timestamp has changed, size has changed + log: [error ][aot] shared class paths mismatch (hint: enable -Xlog:class+path=info to diagnose the failure) + log: [error ][aot] An error has occurred while processing the AOT cache. Run with -Xlog:aot for details. + log: [error ][aot] unrecoverable error + +== a dependency jar has a different modification time (same bytes) + result: started, ready after 3865 ms (Started AotCacheApplication in 3.201 seconds) + log: [warning][aot] This file is not the one used while building the AOT cache: 'lib/spring-core-7.0.9.jar', timestamp has changed + log: [error ][aot] An error has occurred while processing the AOT cache. Run with -Xlog:aot for details. + log: [error ][aot] shared class paths mismatch (hint: enable -Xlog:class+path=info to diagnose the failure) + log: [error ][aot] Unable to map shared spaces + +== the same files copied with plain cp (bytes and path layout identical, timestamps new) + result: started, ready after 4013 ms (Started AotCacheApplication in 3.361 seconds) + log: [warning][aot] This file is not the one used while building the AOT cache: 'app.jar', timestamp has changed + log: [error ][aot] An error has occurred while processing the AOT cache. Run with -Xlog:aot for details. + log: [error ][aot] shared class paths mismatch (hint: enable -Xlog:class+path=info to diagnose the failure) + log: [error ][aot] Unable to map shared spaces + +== the application directory was moved after training (same bytes, different path) + result: started, ready after 2005 ms (Started AotCacheApplication in 1.542 seconds) + log: (nothing at warning or error level) + +== cache trained on the fat jar, run from the extracted layout + result: started, ready after 3900 ms (Started AotCacheApplication in 3.262 seconds) + log: [warning][aot] This file is not the one used while building the AOT cache: 'app.jar', size has changed + log: [error ][aot] An error has occurred while processing the AOT cache. Run with -Xlog:aot for details. + log: [error ][aot] shared class paths mismatch (hint: enable -Xlog:class+path=info to diagnose the failure) + log: [error ][aot] Unable to map shared spaces + +== JDK 25's cache used by JDK 27 + result: started, ready after 4221 ms (Started AotCacheApplication in 3.537 seconds) + log: [warning][aot] The AOT cache version 0x13 does not match the required version 0x14. + log: [warning][aot] The AOT cache has the wrong version. + log: [error ][aot] An error has occurred while processing the AOT cache. Run with -Xlog:aot for details. + log: [error ][aot] Loading static archive failed. + log: [warning][aot] The AOT cache version 0x13 does not match the required version 0x14. + +== the same, with -XX:AOTMode=on + result: did not start (exit code 1) + log: [warning][aot] The AOT cache version 0x13 does not match the required version 0x14. + log: [warning][aot] The AOT cache has the wrong version. + log: [error ][aot] An error has occurred while processing the AOT cache. Run with -Xlog:aot for details. + log: [error ][aot] Loading static archive failed. + log: [warning][aot] The AOT cache version 0x13 does not match the required version 0x14. + +== -XX:+UseCompactObjectHeaders added at run time only (JDK 25) + result: started, ready after 4138 ms (Started AotCacheApplication in 3.446 seconds) + log: [warning][aot] Unable to use AOT cache. + log: [error ][aot] An error has occurred while processing the AOT cache. Run with -Xlog:aot for details. + log: [error ][aot] Loading static archive failed. + log: [error ][aot] Unable to map shared spaces + +== a different collector at run time: -XX:+UseSerialGC + result: started, ready after 1916 ms (Started AotCacheApplication in 1.516 seconds) + log: (nothing at warning or error level) + +== a different collector at run time: -XX:+UseParallelGC + result: started, ready after 1957 ms (Started AotCacheApplication in 1.473 seconds) + log: (nothing at warning or error level) + +== a different collector at run time: -XX:+UseZGC + result: started, ready after 3981 ms (Started AotCacheApplication in 3.347 seconds) + log: [warning][aot] Unable to use AOT cache. +[ ] The saved state of UseCompressedOops and UseCompressedClassPointers is different from runtime, CDS will be disabled. + log: [error ][aot] An error has occurred while processing the AOT cache. Run with -Xlog:aot for details. + log: [error ][aot] Loading static archive failed. + log: [error ][aot] Unable to map shared spaces + +== a different heap size at run time: -Xmx256m + result: started, ready after 2058 ms (Started AotCacheApplication in 1.602 seconds) + log: (nothing at warning or error level) + +== a cache file that does not exist + result: started, ready after 4113 ms (Started AotCacheApplication in 3.451 seconds) + log: [error][aot] An error has occurred while processing the AOT cache. Run with -Xlog:aot for details. + log: [error][aot] Loading static archive failed. + log: [error][aot] Unable to map shared spaces + +== a cache file that does not exist, with -XX:AOTMode=on + result: did not start (exit code 1) + log: [error][aot] An error has occurred while processing the AOT cache. Run with -Xlog:aot for details. + log: [error][aot] Loading static archive failed. + log: [error][aot] Unable to map shared spaces + +# and on JDK 27, whose cache was trained under the default collector (G1): + +== JDK 27, cache from JDK 27 (control) + result: started, ready after 1955 ms (Started AotCacheApplication in 1.547 seconds) + log: (nothing at warning or error level) + +== JDK 27, -XX:+UseZGC at run time + result: started, ready after 4158 ms (Started AotCacheApplication in 3.502 seconds) + log: [warning][aot] Unable to use AOT cache. +[ ] The saved state of UseCompressedOops (1) is different from runtime (0), CDS will be disabled. + log: [error ][aot] An error has occurred while processing the AOT cache. Run with -Xlog:aot for details. + log: [error ][aot] Loading static archive failed. + log: [error ][aot] Unable to map shared spaces + +== JDK 27, -XX:+UseSerialGC at run time + result: started, ready after 2103 ms (Started AotCacheApplication in 1.672 seconds) + log: (nothing at warning or error level) + +== JDK 27, -XX:-UseCompactObjectHeaders at run time + result: started, ready after 3906 ms (Started AotCacheApplication in 3.163 seconds) + log: [warning][aot] Unable to use AOT cache. + log: [error ][aot] An error has occurred while processing the AOT cache. Run with -Xlog:aot for details. + log: [error ][aot] Loading static archive failed. + log: [warning][aot] Unable to use AOT cache. + log: [error ][aot] Loading static archive failed. + +# why ZGC is different: ask the JVM (-Xlog:aot=info) why it refused the G1-trained cache + +== JDK 27, -XX:+UseZGC with the G1-trained cache, -Xlog:aot=info + result: started, ready after 3885 ms (Started AotCacheApplication in 3.258 seconds) + log: [info][aot] The AOT cache was created with UseCompressedOops = 1, UseCompactObjectHeaders = 1 + log: [warning][aot] Unable to use AOT cache. +[ ] The saved state of UseCompressedOops (1) is different from runtime (0), CDS will be disabled. + log: [error ][aot] An error has occurred while processing the AOT cache. Run with -Xlog:aot for details. + log: [error ][aot] Loading static archive failed. + +== JDK 27, cache trained under ZGC, run under ZGC + result: started, ready after 1988 ms (Started AotCacheApplication in 1.554 seconds) + log: (nothing at warning or error level) + +== JDK 27, cache trained under ZGC, run under G1 (compressed oops on again) + result: started, ready after 3869 ms (Started AotCacheApplication in 3.199 seconds) + log: [warning][aot] Unable to use AOT cache. +[ ] The saved state of UseCompressedOops (0) is different from runtime (1), CDS will be disabled. + log: [error ][aot] An error has occurred while processing the AOT cache. Run with -Xlog:aot for details. + log: [error ][aot] Loading static archive failed. + log: [warning][aot] Unable to use AOT cache. + +== JDK 27, cache trained under ZGC, run under G1 with -XX:-UseCompressedOops + result: started, ready after 2018 ms (Started AotCacheApplication in 1.534 seconds) + log: (nothing at warning or error level) + diff --git a/aot-cache/output/05-layers.txt b/aot-cache/output/05-layers.txt new file mode 100644 index 0000000..8c86e28 --- /dev/null +++ b/aot-cache/output/05-layers.txt @@ -0,0 +1,17 @@ +# java -Djarmode=tools -jar app.jar extract --layers (the layer order Spring Boot's Dockerfile support uses) +layer KB gzip KB files +dependencies 22878 20490 47 +spring-boot-loader 0 0 0 +snapshot-dependencies 0 0 0 +application 8 7 1 + +# the AOT cache from the training run (context-only), for scale +refresh.aot 61016 15362 + +# change one line of application code (the unit price in OrderService, built by prepare.sh), extract again +layer before after verdict +dependencies 81a2cb4f9f5c 81a2cb4f9f5c identical: reused from cache/registry +spring-boot-loader abcfa6a9d4df abcfa6a9d4df identical: reused from cache/registry +snapshot-dependencies abcfa6a9d4df abcfa6a9d4df identical: reused from cache/registry +application 4ecdea8230c6 19563974ba7b CHANGED: rebuilt and pushed +AOT cache: trained against the previous jar, so it is stale for the new one (see output/04-mismatch.txt); it must be regenerated after the application layer diff --git a/aot-cache/output/06-sample-endpoints.txt b/aot-cache/output/06-sample-endpoints.txt new file mode 100644 index 0000000..6da04d4 --- /dev/null +++ b/aot-cache/output/06-sample-endpoints.txt @@ -0,0 +1,12 @@ +$ curl localhost:18110/ping +ok [HTTP 200] +$ curl -H 'Content-Type: application/json' -d '{"customer":"asha@example.com","sku":"ABC-1234","quantity":3}' localhost:18110/orders +{"id":1,"customer":"asha@example.com","sku":"ABC-1234","quantity":3,"total":14.97,"createdAt":"2026-09-24T16:35:45.259486118Z"} [HTTP 201] +$ curl localhost:18110/orders/1 +{"id":1,"customer":"asha@example.com","sku":"ABC-1234","quantity":3,"total":14.97,"createdAt":"2026-09-24T16:35:45.259486118Z"} [HTTP 200] +$ curl -H 'Content-Type: application/json' -d '{"customer":"bad","sku":"x","quantity":0}' localhost:18110/orders +{"timestamp":"2026-09-24T16:35:45.351Z","status":400,"error":"Bad Request","path":"/orders"} [HTTP 400] +$ curl localhost:18110/orders/99 + [HTTP 404] +$ curl localhost:18110/actuator/health +{"groups":["liveness","readiness"],"status":"UP"} [HTTP 200] diff --git a/aot-cache/pom.xml b/aot-cache/pom.xml new file mode 100644 index 0000000..31eaaa9 --- /dev/null +++ b/aot-cache/pom.xml @@ -0,0 +1,62 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.1.1 + + + + com.ankurm + aot-cache + 1.0.0 + aot-cache + The JDK 25 AOT cache (JEP 514/515) on a Spring Boot 4.1 service, measured against a plain JVM, AppCDS and a GraalVM native image + + + 25 + + + + + org.springframework.boot + spring-boot-starter-webmvc + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-actuator + + + + + app + + + org.springframework.boot + spring-boot-maven-plugin + + + + + + + native + + + + org.graalvm.buildtools + native-maven-plugin + + + + + + diff --git a/aot-cache/run.sh b/aot-cache/run.sh new file mode 100755 index 0000000..ba9fea6 --- /dev/null +++ b/aot-cache/run.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Regenerates everything under output/. Takes about 30 minutes; the native build alone is about 5. +# Needs JDK 25, JDK 27, a GraalVM for JDK 25 and Maven (paths: scripts/env.sh). Use the machine for nothing else while it runs. +cd "$(dirname "$0")" || exit 1 +source scripts/env.sh +ROUNDS=${ROUNDS:-10} +mkdir -p output +bash scripts/prepare.sh > output/00-prepare.txt 2>&1 +bash scripts/environment.sh > output/00-environment.txt 2>&1 +bash scripts/native.sh > output/03-native-build.txt 2>&1 +bash scripts/train.sh > output/01-training.txt 2>&1 +bash scripts/startup.sh "$ROUNDS" > output/02-startup.txt 2> "$WORK/startup.err" +bash scripts/mismatch.sh > output/04-mismatch.txt 2>&1 +bash scripts/layers.sh > output/05-layers.txt 2>&1 +bash scripts/sample.sh > output/06-sample-endpoints.txt 2>&1 diff --git a/aot-cache/scripts/env.sh b/aot-cache/scripts/env.sh new file mode 100755 index 0000000..5e6ccbb --- /dev/null +++ b/aot-cache/scripts/env.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Where the JDKs and GraalVM live. Override with environment variables; nothing else is hard-coded. +: "${JDK25:=/tmp/tools/j25/jdk-25.0.4.1+1}" +: "${JDK27:=/tmp/tools/j27/jdk-27+35}" +: "${GRAALVM:=$(ls -d /tmp/tools/graal/graalvm-community* 2>/dev/null | head -1)}" +export JDK25 JDK27 GRAALVM +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WORK="$ROOT/work" # generated jars, caches and logs (git-ignored) +OUT="$ROOT/output" # transcripts that the article quotes (committed) +export ROOT WORK OUT +# Maven may need proxy/trust-store options that must not leak into the JVM runs being measured. +MVN_JTO="${JAVA_TOOL_OPTIONS:-}" +unset JAVA_TOOL_OPTIONS JDK_JAVA_OPTIONS _JAVA_OPTIONS +mvn_run() { JAVA_TOOL_OPTIONS="$MVN_JTO" "$@"; } diff --git a/aot-cache/scripts/environment.sh b/aot-cache/scripts/environment.sh new file mode 100755 index 0000000..26a165c --- /dev/null +++ b/aot-cache/scripts/environment.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +source "$(dirname "$0")/env.sh" +echo "# machine"; echo "cpus: $(nproc) memory: $(free -m | awk '/Mem:/ {print $2}') MB kernel: $(uname -r)" +echo; echo "# JDK 25 (the JVM the article is about)"; "$JDK25/bin/java" -version 2>&1 +echo; echo "# JDK 27 (the comparison)"; "$JDK27/bin/java" -version 2>&1 +echo; echo "# GraalVM (native image)"; "$GRAALVM/bin/native-image" --version 2>&1 | head -2 +echo; echo "# libraries"; ls "$WORK/ext/lib" | grep -E '^(spring-boot|spring-core|spring-webmvc|tomcat-embed-core|jackson-databind|hibernate-validator)-[0-9]' | sed 's/\.jar$//' diff --git a/aot-cache/scripts/layers.sh b/aot-cache/scripts/layers.sh new file mode 100755 index 0000000..d4dabd4 --- /dev/null +++ b/aot-cache/scripts/layers.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Docker layer arithmetic without a Docker daemon: which layers of the extracted application change when one line of +# code changes, how big each layer is (uncompressed, and gzipped as a registry would store it), and where the AOT +# cache falls. A layer is "a directory tree"; two builds share a layer when the tree is byte-identical. +source "$(dirname "$0")/env.sh" +cd "$ROOT" + +layer_sum() { # dir -> one hash over every file's path and contents + ( cd "$1" && find . -type f | LC_ALL=C sort | xargs sha256sum | sha256sum | cut -c1-12 ) +} +gz_kb() { tar -C "$1" -cf - . 2>/dev/null | gzip -6 | wc -c | awk '{printf "%d", $1/1024}'; } +kb() { find "$1" -type f -printf '%s\n' | awk '{s+=$1} END{printf "%d", s/1024}'; } + +echo "# java -Djarmode=tools -jar app.jar extract --layers (the layer order Spring Boot's Dockerfile support uses)" +rm -rf "$WORK/layers-a" "$WORK/layers-b" +"$JDK25/bin/java" -Djarmode=tools -jar "$WORK/app.jar" extract --layers --destination "$WORK/layers-a" >/dev/null +printf '%-22s %10s %10s %s\n' layer "KB" "gzip KB" "files" +for d in dependencies spring-boot-loader snapshot-dependencies application; do + printf '%-22s %10s %10s %s\n' "$d" "$(kb "$WORK/layers-a/$d")" "$(gz_kb "$WORK/layers-a/$d")" "$(find "$WORK/layers-a/$d" -type f | wc -l)" +done + +echo +echo "# the AOT cache from the training run (context-only), for scale" +printf '%-22s %10s %10s\n' "refresh.aot" "$(( $(stat -c %s "$WORK/refresh.aot") / 1024 ))" "$(gzip -6 -c "$WORK/refresh.aot" | wc -c | awk '{printf "%d", $1/1024}')" + +echo +echo "# change one line of application code (the unit price in OrderService, built by prepare.sh), extract again" +"$JDK25/bin/java" -Djarmode=tools -jar "$WORK/app-changed.jar" extract --layers --destination "$WORK/layers-b" >/dev/null +printf '%-22s %-14s %-14s %s\n' layer "before" "after" "verdict" +for d in dependencies spring-boot-loader snapshot-dependencies application; do + a=$(layer_sum "$WORK/layers-a/$d"); b=$(layer_sum "$WORK/layers-b/$d") + printf '%-22s %-14s %-14s %s\n' "$d" "$a" "$b" "$([ "$a" = "$b" ] && echo "identical: reused from cache/registry" || echo "CHANGED: rebuilt and pushed")" +done +echo "AOT cache: trained against the previous jar, so it is stale for the new one (see output/04-mismatch.txt); it must be regenerated after the application layer" diff --git a/aot-cache/scripts/lib.sh b/aot-cache/scripts/lib.sh new file mode 100755 index 0000000..a71db40 --- /dev/null +++ b/aot-cache/scripts/lib.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Start / probe / stop helpers shared by the measurement scripts. +source "$(dirname "${BASH_SOURCE[0]}")/env.sh" + +PORT=18110 +BODY='{"customer":"asha@example.com","sku":"ABC-1234","quantity":3}' + +now_ms() { echo $(( $(date +%s%N) / 1000000 )); } + +# start_app WORKDIR LOGFILE cmd args... -> sets APP_PID (the java/native process itself, thanks to exec) +start_app() { + local wd=$1 log=$2; shift 2 + ( cd "$wd" && exec "$@" ) > "$log" 2>&1 & + APP_PID=$! +} + +# wait_ready -> 0 when GET /ping answers 200 within 60 s +wait_ready() { + local i + for i in $(seq 1 3000); do + curl -sf -o /dev/null --max-time 2 "localhost:$PORT/ping" && return 0 + kill -0 "$APP_PID" 2>/dev/null || return 1 + sleep 0.02 + done + return 1 +} + +stop_app() { + kill "$APP_PID" 2>/dev/null + wait "$APP_PID" 2>/dev/null + return 0 +} + +# ms_of SECONDS -> milliseconds with one decimal +ms_of() { awk -v s="$1" 'BEGIN{printf "%.1f", s*1000}'; } + +# post_order -> prints curl's time_total in ms +post_order() { + ms_of "$(curl -s -o /dev/null -w '%{time_total}' -H 'Content-Type: application/json' -d "$BODY" "localhost:$PORT/orders")" +} +get_order() { ms_of "$(curl -s -o /dev/null -w '%{time_total}' "localhost:$PORT/orders/1")"; } + +rss_mb() { awk '/VmRSS/ {printf "%d", $2/1024}' "/proc/$APP_PID/status"; } + +median() { sort -n | awk '{a[NR]=$1} END{ if (NR==0) {print "n/a"; exit} if (NR%2) printf "%s", a[(NR+1)/2]; else printf "%.1f", (a[NR/2]+a[NR/2+1])/2 }'; } +minmax() { sort -n | awk 'NR==1{lo=$1} {hi=$1} END{printf "%s..%s", lo, hi}'; } diff --git a/aot-cache/scripts/mismatch.sh b/aot-cache/scripts/mismatch.sh new file mode 100755 index 0000000..70e1892 --- /dev/null +++ b/aot-cache/scripts/mismatch.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# What happens when the cache does not match the run. Each case starts the service with a cache that was made +# under different conditions, and prints (1) what the JVM logged about the cache, (2) how long start-up took. +# -XX:AOTMode=auto is the default: use the cache if it is valid, otherwise carry on without it. +# -XX:AOTMode=on : refuse to start if the cache cannot be used. +source "$(dirname "$0")/lib.sh" +J25=$JDK25/bin/java; J27=$JDK27/bin/java + +run_case() { # title, workdir, cmd... (runs once with -Xlog:aot=warning so the cache's complaints show up) + local title=$1 wd=$2; shift 2 + local t0 log="$WORK/mismatch.log" + echo "== $title" + t0=$(now_ms) + start_app "$wd" "$log" "$@" --server.port=$PORT + if wait_ready; then + echo " result: started, ready after $(( $(now_ms) - t0 )) ms ($(grep -o 'Started [A-Za-z]* in [0-9.]* seconds' "$log"))" + stop_app + else + wait "$APP_PID" 2>/dev/null + echo " result: did not start (exit code $?)" + fi + grep -E '\[(warning|error)[ ]*\]\[(aot|cds)|saved state of|created with' "$log" | grep -v 'Skipping' | head -5 | sed -E 's/^\[[0-9.]+s\]/ log: /' | cut -c1-170 + [ -z "$(grep -E '\[(warning|error)[ ]*\]\[(aot|cds)' "$log" | grep -v Skipping)" ] && echo " log: (nothing at warning or error level)" + echo +} + +echo "# cache: $WORK/refresh.aot, trained on JDK 25.0.4.1, extracted layout, default GC (G1), 2 CPUs" +echo +run_case "control: the same JVM, the same jar, the same path" "$WORK/ext" "$J25" -XX:AOTCache="$WORK/refresh.aot" -jar app.jar + +# 1. the jar changed after training +# (cp -a everywhere below: plain cp gives every file a new modification time, which the cache checks - see the timestamp case) +rm -rf "$WORK/ext-changed"; cp -a "$WORK/ext" "$WORK/ext-changed" +"$JDK25/bin/java" -Djarmode=tools -jar "$WORK/app-changed.jar" extract --destination "$WORK/changed-tmp" >/dev/null +cp "$WORK/changed-tmp/app-changed.jar" "$WORK/ext-changed/app.jar"; rm -rf "$WORK/changed-tmp" # only the application jar differs +run_case "the application jar was rebuilt after a one-line code change, default AOTMode=auto" "$WORK/ext-changed" "$J25" -XX:AOTCache="$WORK/refresh.aot" -jar app.jar +run_case "the same, with -XX:AOTMode=on" "$WORK/ext-changed" "$J25" -XX:AOTMode=on -XX:AOTCache="$WORK/refresh.aot" -jar app.jar + +# 2. a dependency jar changed +rm -rf "$WORK/ext-dep"; cp -a "$WORK/ext" "$WORK/ext-dep" +( cd "$WORK/ext-dep/lib" && f=$(ls spring-core-*.jar) && touch -d '2001-01-01' "$f" ) +run_case "a dependency jar has a different modification time (same bytes)" "$WORK/ext-dep" "$J25" -XX:AOTCache="$WORK/refresh.aot" -jar app.jar + +# 2b. only a copy, with fresh timestamps (what `cp -r` or a re-extract does) +rm -rf "$WORK/ext-copy"; cp -r "$WORK/ext" "$WORK/ext-copy" +run_case "the same files copied with plain cp (bytes and path layout identical, timestamps new)" "$WORK/ext-copy" "$J25" -XX:AOTCache="$WORK/refresh.aot" -jar app.jar + +# 3. moved to another directory +rm -rf "$WORK/moved"; mkdir -p "$WORK/moved"; cp -a "$WORK/ext" "$WORK/moved/app-dir" +run_case "the application directory was moved after training (same bytes, different path)" "$WORK/moved/app-dir" "$J25" -XX:AOTCache="$WORK/refresh.aot" -jar app.jar + +# 4. fat jar cache used with the extracted layout +run_case "cache trained on the fat jar, run from the extracted layout" "$WORK/ext" "$J25" -XX:AOTCache="$WORK/fat.aot" -jar app.jar + +# 5. different JVM +run_case "JDK 25's cache used by JDK 27" "$WORK/ext" "$J27" -XX:AOTCache="$WORK/refresh.aot" -jar app.jar +run_case "the same, with -XX:AOTMode=on" "$WORK/ext" "$J27" -XX:AOTMode=on -XX:AOTCache="$WORK/refresh.aot" -jar app.jar + +# 6. flags that change what the cache holds +run_case "-XX:+UseCompactObjectHeaders added at run time only (JDK 25)" "$WORK/ext" "$J25" -XX:+UseCompactObjectHeaders -XX:AOTCache="$WORK/refresh.aot" -jar app.jar +run_case "a different collector at run time: -XX:+UseSerialGC" "$WORK/ext" "$J25" -XX:+UseSerialGC -XX:AOTCache="$WORK/refresh.aot" -jar app.jar +run_case "a different collector at run time: -XX:+UseParallelGC" "$WORK/ext" "$J25" -XX:+UseParallelGC -XX:AOTCache="$WORK/refresh.aot" -jar app.jar +run_case "a different collector at run time: -XX:+UseZGC" "$WORK/ext" "$J25" -XX:+UseZGC -XX:AOTCache="$WORK/refresh.aot" -jar app.jar +run_case "a different heap size at run time: -Xmx256m" "$WORK/ext" "$J25" -Xmx256m -XX:AOTCache="$WORK/refresh.aot" -jar app.jar +run_case "a cache file that does not exist" "$WORK/ext" "$J25" -XX:AOTCache="$WORK/nonexistent.aot" -jar app.jar +run_case "a cache file that does not exist, with -XX:AOTMode=on" "$WORK/ext" "$J25" -XX:AOTMode=on -XX:AOTCache="$WORK/nonexistent.aot" -jar app.jar + +echo "# and on JDK 27, whose cache was trained under the default collector (G1):" +echo +run_case "JDK 27, cache from JDK 27 (control)" "$WORK/ext" "$J27" -XX:AOTCache="$WORK/refresh27.aot" -jar app.jar +run_case "JDK 27, -XX:+UseZGC at run time" "$WORK/ext" "$J27" -XX:+UseZGC -XX:AOTCache="$WORK/refresh27.aot" -jar app.jar +run_case "JDK 27, -XX:+UseSerialGC at run time" "$WORK/ext" "$J27" -XX:+UseSerialGC -XX:AOTCache="$WORK/refresh27.aot" -jar app.jar +run_case "JDK 27, -XX:-UseCompactObjectHeaders at run time" "$WORK/ext" "$J27" -XX:-UseCompactObjectHeaders -XX:AOTCache="$WORK/refresh27.aot" -jar app.jar + +echo "# why ZGC is different: ask the JVM (-Xlog:aot=info) why it refused the G1-trained cache" +echo +run_case "JDK 27, -XX:+UseZGC with the G1-trained cache, -Xlog:aot=info" "$WORK/ext" "$J27" -XX:+UseZGC -Xlog:aot=info -XX:AOTCache="$WORK/refresh27.aot" -jar app.jar +run_case "JDK 27, cache trained under ZGC, run under ZGC" "$WORK/ext" "$J27" -XX:+UseZGC -XX:AOTCache="$WORK/zgc27.aot" -jar app.jar +run_case "JDK 27, cache trained under ZGC, run under G1 (compressed oops on again)" "$WORK/ext" "$J27" -XX:AOTCache="$WORK/zgc27.aot" -jar app.jar +run_case "JDK 27, cache trained under ZGC, run under G1 with -XX:-UseCompressedOops" "$WORK/ext" "$J27" -XX:-UseCompressedOops -XX:AOTCache="$WORK/zgc27.aot" -jar app.jar diff --git a/aot-cache/scripts/native.sh b/aot-cache/scripts/native.sh new file mode 100755 index 0000000..a849aaf --- /dev/null +++ b/aot-cache/scripts/native.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Builds the GraalVM native image of the same service (no reflection tricks: the app has none). Takes minutes. +source "$(dirname "$0")/env.sh" +cd "$ROOT"; mkdir -p "$WORK" +t0=$(date +%s) +JAVA_HOME=$GRAALVM mvn_run mvn -B -DskipTests -Pnative native:compile > "$WORK/native-build.log" 2>&1 +rc=$? +echo "# GraalVM: $("$GRAALVM/bin/native-image" --version 2>&1 | head -1)" +grep -E "types, .* fields, and .* methods found reachable|Peak RSS|Finished generating|BUILD (SUCCESS|FAILURE)|Total time" "$WORK/native-build.log" | sed -E 's/^\[INFO\] //; s/^ +//' +[ $rc -eq 0 ] && cp "target/aot-cache" "$WORK/aot-cache-native" && ls -l "$WORK/aot-cache-native" "$WORK/app.jar" | awk '{printf "%-30s %d MB\n", $NF, $5/1048576}' +echo "build wall time: $(( $(date +%s) - t0 )) s" diff --git a/aot-cache/scripts/prepare.sh b/aot-cache/scripts/prepare.sh new file mode 100755 index 0000000..7607f62 --- /dev/null +++ b/aot-cache/scripts/prepare.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Builds everything the measurements need under work/: the plain jar, a jar with Spring's own AOT output, +# the extracted layout, an AppCDS archive and the AOT caches. Prints what it built and how big each artifact is. +source "$(dirname "$0")/env.sh" +set -e +cd "$ROOT" +rm -rf "$WORK"; mkdir -p "$WORK" + +echo "== build: plain jar (mvn package)" +rm -rf "$ROOT/target" # a stale target/ would carry Spring AOT output from an earlier -Pnative build into the plain jar +JAVA_HOME=$JDK25 mvn_run mvn -q -B -DskipTests package > "$WORK/build-plain.log" 2>&1 +cp target/app.jar "$WORK/app.jar" +rm -rf "$ROOT/target" +echo "== build: jar with Spring AOT output (mvn -Pnative package; this does NOT compile a native image)" +JAVA_HOME=$JDK25 mvn_run mvn -q -B -DskipTests -Pnative package > "$WORK/build-springaot.log" 2>&1 +cp target/app.jar "$WORK/app-springaot.jar" +echo "initializers in plain jar: $(unzip -l "$WORK/app.jar" | grep -c '__ApplicationContextInitializer')" +echo "initializers in Spring AOT jar: $(unzip -l "$WORK/app-springaot.jar" | grep -c '__ApplicationContextInitializer')" + +echo "== extract both jars (java -Djarmode=tools -jar app.jar extract)" +"$JDK25/bin/java" -Djarmode=tools -jar "$WORK/app.jar" extract --destination "$WORK/ext" >/dev/null +"$JDK25/bin/java" -Djarmode=tools -jar "$WORK/app-springaot.jar" extract --destination "$WORK/ext-springaot" >/dev/null +mv "$WORK/ext-springaot/app-springaot.jar" "$WORK/ext-springaot/app.jar" # the launcher jar keeps its lib/ Class-Path +ls "$WORK/ext" "$WORK/ext-springaot" + +echo "== build: the same service after a one-line change (the unit price in OrderService), for the mismatch and layer checks" +mkdir -p "$WORK/changed-src" +cp -r pom.xml src "$WORK/changed-src/" +sed -i 's/BigDecimal.valueOf(4_99, 2)/BigDecimal.valueOf(5_99, 2)/' "$WORK/changed-src/src/main/java/com/ankurm/aotcache/OrderService.java" +grep -n 'valueOf(5_99' "$WORK/changed-src/src/main/java/com/ankurm/aotcache/OrderService.java" | sed 's/^/edited: /' +( cd "$WORK/changed-src" && JAVA_HOME=$JDK25 mvn_run mvn -q -B -DskipTests package > "$WORK/build-changed.log" 2>&1 ) +cp "$WORK/changed-src/target/app.jar" "$WORK/app-changed.jar" diff --git a/aot-cache/scripts/sample.sh b/aot-cache/scripts/sample.sh new file mode 100755 index 0000000..90971ea --- /dev/null +++ b/aot-cache/scripts/sample.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# A transcript of the service's own endpoints, so the reader knows what the training run's traffic is. +source "$(dirname "$0")/lib.sh" +start_app "$WORK/ext" "$WORK/sample.log" "$JDK25/bin/java" -jar app.jar --server.port=$PORT +wait_ready || { echo "did not start"; exit 1; } +show() { # print the command the way a shell would need it typed, then run it + printf '$'; for a in "$@"; do case $a in *[' {}"']*) printf " '%s'" "$a";; *) printf ' %s' "$a";; esac; done; echo + "$@" -s -w ' [HTTP %{http_code}]\n' +} +show curl "localhost:$PORT/ping" +show curl -H 'Content-Type: application/json' -d "$BODY" "localhost:$PORT/orders" +show curl "localhost:$PORT/orders/1" +show curl -H 'Content-Type: application/json' -d '{"customer":"bad","sku":"x","quantity":0}' "localhost:$PORT/orders" +show curl "localhost:$PORT/orders/99" +show curl "localhost:$PORT/actuator/health" +stop_app +exit 0 diff --git a/aot-cache/scripts/startup.sh b/aot-cache/scripts/startup.sh new file mode 100755 index 0000000..8a8cb7e --- /dev/null +++ b/aot-cache/scripts/startup.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Startup and first-request latency for every configuration. Configurations are run in interleaved rounds +# (round 1 runs all of them once, then round 2, ...) so slow drift on a shared machine hits all of them alike. +# scripts/startup.sh [ROUNDS] default 10 +source "$(dirname "$0")/lib.sh" +ROUNDS=${1:-10} +LOG="$WORK/startup-runs.txt"; : > "$LOG" + +declare -A WD CMD +labels=() +add_cfg() { local l=$1 wd=$2; shift 2; labels+=("$l"); WD[$l]=$wd; CMD[$l]="$*"; } + +J25=$JDK25/bin/java; J27=$JDK27/bin/java +add_cfg "1 jvm-fat-jar" "$WORK" "$J25 -jar app.jar" +add_cfg "2 jvm-extracted" "$WORK/ext" "$J25 -jar app.jar" +add_cfg "3 jvm+spring-aot" "$WORK/ext-springaot" "$J25 -Dspring.aot.enabled=true -jar app.jar" +add_cfg "4 appcds" "$WORK/ext" "$J25 -XX:SharedArchiveFile=$WORK/appcds.jsa -jar app.jar" +add_cfg "5 aot-cache fat-jar" "$WORK" "$J25 -XX:AOTCache=$WORK/fat.aot -jar app.jar" +add_cfg "6 aot-cache context-only" "$WORK/ext" "$J25 -XX:AOTCache=$WORK/refresh.aot -jar app.jar" +add_cfg "7 aot-cache traffic" "$WORK/ext" "$J25 -XX:AOTCache=$WORK/traffic.aot -jar app.jar" +add_cfg "8 aot-cache+spring-aot" "$WORK/ext-springaot" "$J25 -Dspring.aot.enabled=true -XX:AOTCache=$WORK/springaot.aot -jar app.jar" +add_cfg "9 aot-cache+spring-aot traffic" "$WORK/ext-springaot" "$J25 -Dspring.aot.enabled=true -XX:AOTCache=$WORK/springaot-traffic.aot -jar app.jar" +add_cfg "10 jdk27 plain" "$WORK/ext" "$J27 -jar app.jar" +add_cfg "11 jdk27 aot-cache" "$WORK/ext" "$J27 -XX:AOTCache=$WORK/refresh27.aot -jar app.jar" +[ -x "$WORK/aot-cache-native" ] && add_cfg "12 native image" "$WORK" "$WORK/aot-cache-native" + +one_run() { # label round + local l=$1 r=$2 t0 ready started first second med50 rss + t0=$(now_ms) + start_app "${WD[$l]}" "$WORK/run.log" ${CMD[$l]} --server.port=$PORT + if ! wait_ready; then echo "RUN|$l|$r|FAILED" >> "$LOG"; stop_app; return; fi + ready=$(( $(now_ms) - t0 )) + started=$(grep -o 'Started [A-Za-z]* in [0-9.]* seconds' "$WORK/run.log" | awk '{print $4*1000}') + first=$(post_order); second=$(get_order) + med50=$(for i in $(seq 1 50); do post_order; echo; done | grep . | median) + rss=$(rss_mb) + echo "RUN|$l|$r|ready=$ready|started=${started:-n/a}|first=$first|second=$second|next50=$med50|rss=$rss" >> "$LOG" + stop_app; sleep 0.5 +} + +echo "# ${#labels[@]} configurations x $ROUNDS rounds; one unrecorded warm-up round first (so cache files are in the page cache)" +for l in "${labels[@]}"; do one_run "$l" 0; done; : > "$LOG" +for r in $(seq 1 $ROUNDS); do + for l in "${labels[@]}"; do one_run "$l" "$r"; done + echo "round $r done" >&2 +done + +field() { grep -F "RUN|$1|" "$LOG" | grep -o "$2=[0-9.]*" | cut -d= -f2; } +echo +echo "== medians of $ROUNDS runs (min..max in brackets), milliseconds; rss in MB" +printf '%-32s %-20s %-20s %-20s %-18s %s\n' "configuration" "ready (exec->200)" "Boot 'Started in'" "1st POST /orders" "next 50 (median)" "RSS" +for l in "${labels[@]}"; do + f=$(grep -cF "RUN|$l|" "$LOG"); ok=$(grep -F "RUN|$l|" "$LOG" | grep -vc FAILED) + cell() { local v; v=$(field "$l" "$1"); printf '%-20s ' "$(echo "$v" | median) [$(echo "$v" | minmax)]"; } + printf '%-32s ' "$l"; cell ready; cell started; cell first; printf '%-18s ' "$(field "$l" next50 | median)"; printf '%s' "$(field "$l" rss | median)" + [ "$ok" != "$f" ] && printf ' (%s of %s runs failed)' $((f-ok)) $f + echo +done +echo +echo "== every run" +sed 's/^RUN|//; s/|/ /g' "$LOG" diff --git a/aot-cache/scripts/train.sh b/aot-cache/scripts/train.sh new file mode 100755 index 0000000..d77f893 --- /dev/null +++ b/aot-cache/scripts/train.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# The training runs: one per cache flavour. Prints how long each took, how big the artifact is, and what the +# JVM logged at warning level while writing it. Needs scripts/prepare.sh to have run. +source "$(dirname "$0")/lib.sh" + +exercise() { # the traffic a "real" training run sends: success, validation failure, 404, actuator + for i in 1 2 3 4 5; do post_order >/dev/null; done + get_order >/dev/null + curl -s -o /dev/null -H 'Content-Type: application/json' -d '{"customer":"bad","sku":"x","quantity":0}' "localhost:$PORT/orders" + curl -s -o /dev/null "localhost:$PORT/orders/99" + curl -s -o /dev/null "localhost:$PORT/actuator/health" +} + +report() { # label file t0 + printf '%-34s %6d ms %s (%s MB)\n' "$1" $(( $(now_ms) - $3 )) "$(basename "$2")" "$(( $(stat -c %s "$2") / 1048576 ))" +} + +echo "# training runs (JDK 25.0.4.1 unless noted); wall time includes JVM start, context start and cache creation" + +t0=$(now_ms) +( cd "$WORK/ext" && "$JDK25/bin/java" -XX:ArchiveClassesAtExit="$WORK/appcds.jsa" -Dspring.context.exit=onRefresh -jar app.jar ) > "$WORK/train-appcds.log" 2>&1 +report "AppCDS (ArchiveClassesAtExit)" "$WORK/appcds.jsa" $t0 + +t0=$(now_ms) +"$JDK25/bin/java" -XX:AOTCacheOutput="$WORK/fat.aot" -Dspring.context.exit=onRefresh -jar "$WORK/app.jar" > "$WORK/train-fat.log" 2>&1 +report "AOT cache, fat jar, context-only" "$WORK/fat.aot" $t0 + +t0=$(now_ms) +( cd "$WORK/ext" && "$JDK25/bin/java" -XX:AOTCacheOutput="$WORK/refresh.aot" -Dspring.context.exit=onRefresh -jar app.jar ) > "$WORK/train-refresh.log" 2>&1 +report "AOT cache, context-only training" "$WORK/refresh.aot" $t0 + +t0=$(now_ms) +start_app "$WORK/ext" "$WORK/train-traffic.log" "$JDK25/bin/java" -XX:AOTCacheOutput="$WORK/traffic.aot" -jar app.jar --server.port=$PORT +wait_ready && exercise +kill -TERM "$APP_PID"; wait "$APP_PID" 2>/dev/null +report "AOT cache, traffic training" "$WORK/traffic.aot" $t0 + +t0=$(now_ms) +( cd "$WORK/ext-springaot" && "$JDK25/bin/java" -Dspring.aot.enabled=true -XX:AOTCacheOutput="$WORK/springaot.aot" -Dspring.context.exit=onRefresh -jar app.jar ) > "$WORK/train-springaot.log" 2>&1 +report "AOT cache + Spring AOT, context-only" "$WORK/springaot.aot" $t0 + +t0=$(now_ms) +start_app "$WORK/ext-springaot" "$WORK/train-springaot-traffic.log" "$JDK25/bin/java" -Dspring.aot.enabled=true -XX:AOTCacheOutput="$WORK/springaot-traffic.aot" -jar app.jar --server.port=$PORT +wait_ready && exercise +kill -TERM "$APP_PID"; wait "$APP_PID" 2>/dev/null +report "AOT cache + Spring AOT, traffic" "$WORK/springaot-traffic.aot" $t0 + +t0=$(now_ms) +( cd "$WORK/ext" && "$JDK27/bin/java" -XX:AOTCacheOutput="$WORK/refresh27.aot" -Dspring.context.exit=onRefresh -jar app.jar ) > "$WORK/train-refresh27.log" 2>&1 +report "AOT cache on JDK 27, context-only" "$WORK/refresh27.aot" $t0 + +t0=$(now_ms) +( cd "$WORK/ext" && "$JDK27/bin/java" -XX:+UseZGC -XX:AOTCacheOutput="$WORK/zgc27.aot" -Dspring.context.exit=onRefresh -jar app.jar ) > "$WORK/train-zgc27.log" 2>&1 +report "AOT cache on JDK 27, trained under ZGC" "$WORK/zgc27.aot" $t0 + +echo +echo "# what the JVM said at warning level while writing the context-only AOT cache (count, then the distinct kinds)" +grep -c '\[warning\]\[aot\]' "$WORK/train-refresh.log" | sed 's/^/warning lines: /' +grep '\[warning\]\[aot\]' "$WORK/train-refresh.log" | sed -E 's/^\[[0-9.]+s\]//; s/(Skipping) [^ ]+:/\1 :/' | sort | uniq -c | sort -rn | head -5 diff --git a/aot-cache/src/main/java/com/ankurm/aotcache/AotCacheApplication.java b/aot-cache/src/main/java/com/ankurm/aotcache/AotCacheApplication.java new file mode 100644 index 0000000..78404b8 --- /dev/null +++ b/aot-cache/src/main/java/com/ankurm/aotcache/AotCacheApplication.java @@ -0,0 +1,12 @@ +package com.ankurm.aotcache; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class AotCacheApplication { + + public static void main(String[] args) { + SpringApplication.run(AotCacheApplication.class, args); + } +} diff --git a/aot-cache/src/main/java/com/ankurm/aotcache/NewOrder.java b/aot-cache/src/main/java/com/ankurm/aotcache/NewOrder.java new file mode 100644 index 0000000..8cda697 --- /dev/null +++ b/aot-cache/src/main/java/com/ankurm/aotcache/NewOrder.java @@ -0,0 +1,14 @@ +package com.ankurm.aotcache; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +/** The request body of POST /orders. Bean Validation and Jackson both have to work on it. */ +public record NewOrder( + @NotBlank @Email String customer, + @NotBlank @Pattern(regexp = "[A-Z]{3}-[0-9]{4}") String sku, + @Min(1) @Max(1000) int quantity) { +} diff --git a/aot-cache/src/main/java/com/ankurm/aotcache/Order.java b/aot-cache/src/main/java/com/ankurm/aotcache/Order.java new file mode 100644 index 0000000..9375366 --- /dev/null +++ b/aot-cache/src/main/java/com/ankurm/aotcache/Order.java @@ -0,0 +1,8 @@ +package com.ankurm.aotcache; + +import java.math.BigDecimal; +import java.time.Instant; + +/** A stored order. */ +public record Order(long id, String customer, String sku, int quantity, BigDecimal total, Instant createdAt) { +} diff --git a/aot-cache/src/main/java/com/ankurm/aotcache/OrderController.java b/aot-cache/src/main/java/com/ankurm/aotcache/OrderController.java new file mode 100644 index 0000000..f25e30c --- /dev/null +++ b/aot-cache/src/main/java/com/ankurm/aotcache/OrderController.java @@ -0,0 +1,38 @@ +package com.ankurm.aotcache; + +import java.net.URI; + +import jakarta.validation.Valid; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class OrderController { + + private final OrderService service; + + public OrderController(OrderService service) { + this.service = service; + } + + @GetMapping("/ping") + public String ping() { + return "ok"; + } + + @PostMapping("/orders") + public ResponseEntity create(@Valid @RequestBody NewOrder body) { + Order o = service.create(body); + return ResponseEntity.created(URI.create("/orders/" + o.id())).body(o); + } + + @GetMapping("/orders/{id}") + public ResponseEntity get(@PathVariable long id) { + return service.find(id).map(ResponseEntity::ok).orElseGet(() -> ResponseEntity.notFound().build()); + } +} diff --git a/aot-cache/src/main/java/com/ankurm/aotcache/OrderService.java b/aot-cache/src/main/java/com/ankurm/aotcache/OrderService.java new file mode 100644 index 0000000..a41d98d --- /dev/null +++ b/aot-cache/src/main/java/com/ankurm/aotcache/OrderService.java @@ -0,0 +1,29 @@ +package com.ankurm.aotcache; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +import org.springframework.stereotype.Service; + +@Service +public class OrderService { + + private final AtomicLong ids = new AtomicLong(); + private final Map orders = new ConcurrentHashMap<>(); + + public Order create(NewOrder in) { + long id = ids.incrementAndGet(); + BigDecimal unit = BigDecimal.valueOf(4_99, 2); + Order o = new Order(id, in.customer(), in.sku(), in.quantity(), unit.multiply(BigDecimal.valueOf(in.quantity())), Instant.now()); + orders.put(id, o); + return o; + } + + public Optional find(long id) { + return Optional.ofNullable(orders.get(id)); + } +} diff --git a/aot-cache/src/main/resources/application.properties b/aot-cache/src/main/resources/application.properties new file mode 100644 index 0000000..2433d4b --- /dev/null +++ b/aot-cache/src/main/resources/application.properties @@ -0,0 +1,2 @@ +spring.application.name=aot-cache +management.endpoints.web.exposure.include=health