Compare commits

..
17 Commits
Author SHA1 Message Date
Claude 008211f2fa Add file-transfer module: multipart limits, filename handling, Range downloads, streaming under -Xmx64m, S3 presigned URLs
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VRLbHYdXVEBzGVhYq3pz4q
2026-10-17 15:41:24 +00:00
Claude b9388053c4 Add rest-client-test module: @RestClientTest on Boot 4.1, Jackson 3 by default, and the RestClient-vs-RestTemplate root-URI matching trap (post #49) 2026-10-08 16:40:51 +00:00
Claude 4370ac7fdf Correct post #45: Flyway and Liquibase DO run under @DataJpaTest by default
The previous commit's FlywayDoesNotRunUnderPlainDataJpaTestTest and
FlywayRunsWhenExplicitlyImportedTest rested on a false premise: that none of
@DataJpaTest's four documented meta-annotations import FlywayAutoConfiguration,
so migrations never run under the slice. That reading of the four .imports
manifests is accurate but incomplete. @AutoConfigureJdbc is itself
meta-annotated with @AutoConfigureDataSourceInitialization (new in Boot 4.0),
and Spring's ImportAutoConfigurationImportSelector walks the full
meta-annotation tree transitively. That fifth annotation's own .imports
resource is loaded via ClassLoader.getResources(), which merges same-named
files from every jar on the classpath -- spring-boot-jdbc-test,
spring-boot-flyway and spring-boot-liquibase each ship one at the identical
path, contributing DataSourceInitializationAutoConfiguration,
FlywayAutoConfiguration and LiquibaseAutoConfiguration respectively.

Replaced the two wrong-premise test classes with FlywayRunsByDefaultTest
(proves the bean is present and the migration actually ran) and
ExplicitFlywayImportIsRedundantTest (proves the old "add the import yourself"
advice is harmless but unnecessary on Boot 4.1). Added javap/unzip evidence
for the new mechanism to docs/output, re-captured the full test run (9 tests,
1 clean skip), corrected README's test table, and closed a second,
unrelated gap: the unzip -l listing quoted in the @ServiceConnection section
had no matching captured transcript -- it does now.
2026-10-08 12:35:04 +00:00
Claude 97f946136b Add data-jpa-testcontainers module: @DataJpaTest's default substitution, the Flyway-never-runs trap, and @ServiceConnection on Testcontainers 2.0.5 (post #45) 2026-10-08 11:52:08 +00:00
Claude 5e4d481c02 Add rest-test-client module: RestTestClient across all four binding modes, TestRestTemplate relocation, gzip-hiding HTTP client surprise (post #44) 2026-10-08 09:40:13 +00:00
Claude 5851521420 Add testing-no-containers module: WireMock, MockWebServer, @MockitoBean/@MockitoSpyBean (post #43) 2026-10-08 06:19:52 +00:00
Claude f736108067 Add kotlin module: null safety, coroutines, allopen/noarg, JSpecify interop (post #42) 2026-10-08 05:35:15 +00:00
Claude 83d245e726 hexagonal: capture the real missing-scan-base-packages failure as a transcript
A follow-up to the hexagonal module commit: the post's self-correction pass caught a
console block quoting that exception from memory instead of a committed transcript, so
this captures it for real (by removing @EnableJpaRepositories/@EntityScan, running the
test, and restoring them) and links the README to it. A regular git commit --amend +
force-push would have kept this at one commit for the module, matching every other
module in this repo, but this session's git safety controls do not allow a force-push,
so this lands as its own small commit instead.
2026-10-03 20:35:59 +00:00
Claude fd6d0fa1e7 Add the hexagonal module: Hexagonal Architecture (Ports and Adapters) in Spring Boot 4 2026-10-03 20:28:16 +00:00
asmhatreandClaude Sonnet 5 ae681f85c4 Add batch-5-to-6 module: Spring Batch 5.2 to 6.0 migration on Boot 3.5 to 4.1
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Gth99spB1CzQmBFofXmU4Z
2026-09-28 09:09:50 +00:00
asmhatreandClaude Sonnet 5 804c33f0d5 Add gradle9 module: Spring Boot 4 build migrated from Gradle 8 to Gradle 9
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Gth99spB1CzQmBFofXmU4Z
2026-09-28 09:00:03 +00:00
Claude b4b43b94bf Add openrewrite module: Boot 2.7 to 3.5 to 4.0 upgrade with OpenRewrite recipes, leftovers and CI gate
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Gth99spB1CzQmBFofXmU4Z
2026-09-28 08:40:08 +00:00
Claude e4f927d7af Add jakarta-ee-11 module: Jakarta EE 10 vs 11 API comparison against Spring Boot 4.1.1
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Gth99spB1CzQmBFofXmU4Z
2026-09-28 08:40:07 +00:00
Claude acd63e658b Add boot42-preview module: Spring Boot 4.2.0-M2 vs 4.1.1 forwarded headers, AMQP starters, buildpack cache, RestTemplate removal warnings
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Gth99spB1CzQmBFofXmU4Z
2026-09-28 08:40:07 +00:00
Claude 3e2029ab3a Add aot-cache module: the JDK 25 AOT cache on Spring Boot 4.1 vs AppCDS, Spring AOT and GraalVM native
A Spring Boot 4.1.1 order service started 12 ways (plain and extracted jar, Spring AOT output, AppCDS, AOT cache trained with and without traffic, JDK 27, native image), ten interleaved rounds each, with first-request latency; 24 cache-mismatch cases; Docker layer arithmetic. Transcripts are in output/ (no docs/ folder).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TF9JWFvJSNm6HVzswzZU5a
2026-09-24 16:44:16 +00:00
Claude fa4205f631 README: list the custom-starter, core-events, multi-datasource and i18n modules and the circular-dependencies article
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Uu7q8vPeREyT4218EJPzz1
2026-09-24 10:01:37 +00:00
Claude a2cc7bbd08 i18n: say only what was observed about the leaked JVM default locale
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Uu7q8vPeREyT4218EJPzz1
2026-09-24 10:01:36 +00:00
299 changed files with 10114 additions and 7 deletions
+20 -4
View File
@@ -9,6 +9,7 @@ files.
| Directory | Article | What it demonstrates | | Directory | Article | What it demonstrates |
|---|---|---| |---|---|---|
| [`hexagonal/`](hexagonal) | [Hexagonal Architecture (Ports and Adapters) in Spring Boot 4](https://ankurm.com/hexagonal-architecture-ports-and-adapters-spring-boot-4/) | a 4-module Maven reactor that makes "the core has no Spring dependency" something `mvn dependency:tree` proves rather than a naming convention, testing the core with zero Spring context, and a Spring Boot 4 test-slice-artifact split (`@DataJpaTest`, `@WebMvcTest`, `TestRestTemplate`) hit and fixed along the way |
| [`actuator-in-production/`](actuator-in-production) | [Spring Boot Actuator in Production](https://ankurm.com/spring-boot-actuator-production-endpoints-security-health-indicators/) | endpoint exposure defaults, securing Actuator, custom health indicators and how they hang | | [`actuator-in-production/`](actuator-in-production) | [Spring Boot Actuator in Production](https://ankurm.com/spring-boot-actuator-production-endpoints-security-health-indicators/) | endpoint exposure defaults, securing Actuator, custom health indicators and how they hang |
| [`spring-boot-startup-time/`](spring-boot-startup-time) | [Why Your Spring Boot App Takes 8 Seconds to Start](spring-boot-startup-time/post/post.md) | `BufferingApplicationStartup`, JFR startup events, self time vs total time, the classpath-scan tax, the JDK 25 AOT cache | | [`spring-boot-startup-time/`](spring-boot-startup-time) | [Why Your Spring Boot App Takes 8 Seconds to Start](spring-boot-startup-time/post/post.md) | `BufferingApplicationStartup`, JFR startup events, self time vs total time, the classpath-scan tax, the JDK 25 AOT cache |
| [`configuration-properties/`](configuration-properties) | [@ConfigurationProperties vs @Value in Spring Boot 4](https://ankurm.com/) | relaxed binding measured three ways, record constructor binding, validation, IDE metadata generation | | [`configuration-properties/`](configuration-properties) | [@ConfigurationProperties vs @Value in Spring Boot 4](https://ankurm.com/) | relaxed binding measured three ways, record constructor binding, validation, IDE metadata generation |
@@ -32,11 +33,26 @@ files.
| [`etag-caching/`](etag-caching) | [Mastering Cache Control with ETag in Spring Boot RESTful APIs](https://ankurm.com/etag-cache-control-rest-api-spring-boot/) | `spring-boot-starter-web`'s own POM now reading "deprecated in favor of spring-boot-starter-webmvc", `ShallowEtagHeaderFilter` and `WebRequest.checkNotModified()` re-verified unchanged on Spring Framework 7, deep cache vs shallow cache, and conditional `PUT` with `If-Match` as optimistic locking | | [`etag-caching/`](etag-caching) | [Mastering Cache Control with ETag in Spring Boot RESTful APIs](https://ankurm.com/etag-cache-control-rest-api-spring-boot/) | `spring-boot-starter-web`'s own POM now reading "deprecated in favor of spring-boot-starter-webmvc", `ShallowEtagHeaderFilter` and `WebRequest.checkNotModified()` re-verified unchanged on Spring Framework 7, deep cache vs shallow cache, and conditional `PUT` with `If-Match` as optimistic locking |
| [`restclient-basic-auth/`](restclient-basic-auth) | [Spring Boot RestTemplate with Basic Auth: A Modern Guide](https://ankurm.com/spring-boot-resttemplate-with-basic-auth-a-modern-guide/) | RestClient with Basic Auth two ways against a real embedded server, `{noop}` passwords confirmed to emit no runtime warning at all, `spring-boot-starter-restclient` as its own required Boot 4 module, and the `RestClient.exchange()` trap covered in depth by the [RestTemplate to RestClient migration guide](https://ankurm.com/resttemplate-to-restclient-migration-guide/) | | [`restclient-basic-auth/`](restclient-basic-auth) | [Spring Boot RestTemplate with Basic Auth: A Modern Guide](https://ankurm.com/spring-boot-resttemplate-with-basic-auth-a-modern-guide/) | RestClient with Basic Auth two ways against a real embedded server, `{noop}` passwords confirmed to emit no runtime warning at all, `spring-boot-starter-restclient` as its own required Boot 4 module, and the `RestClient.exchange()` trap covered in depth by the [RestTemplate to RestClient migration guide](https://ankurm.com/resttemplate-to-restclient-migration-guide/) |
| [`core-di/`](core-di) | [Dependency Injection in Spring Boot 4: Constructor vs Setter vs Field (and Why Field Injection Hurts)](https://ankurm.com/spring-boot-4-dependency-injection-constructor-setter-field/) and [@Autowired Explained: By-Type Resolution, @Qualifier, @Primary, ObjectProvider and List Injection](https://ankurm.com/spring-autowired-qualifier-primary-objectprovider-list-injection/) | one `OrderService` in three injection styles built with plain `new`, a field-injected dependency used in a constructor, circular dependencies in plain Spring vs Boot with the real failure report, `@Lazy` injecting a proxy, and the `@Autowired` resolution ladder read from Spring 7.0.9 bytecode (name match beats `@Priority`), plus the empty-collection trap that only bites field and setter injection | | [`core-di/`](core-di) | [Dependency Injection in Spring Boot 4: Constructor vs Setter vs Field (and Why Field Injection Hurts)](https://ankurm.com/spring-boot-4-dependency-injection-constructor-setter-field/) and [@Autowired Explained: By-Type Resolution, @Qualifier, @Primary, ObjectProvider and List Injection](https://ankurm.com/spring-autowired-qualifier-primary-objectprovider-list-injection/) | one `OrderService` in three injection styles built with plain `new`, a field-injected dependency used in a constructor, circular dependencies in plain Spring vs Boot with the real failure report, `@Lazy` injecting a proxy, and the `@Autowired` resolution ladder read from Spring 7.0.9 bytecode (name match beats `@Priority`), plus the empty-collection trap that only bites field and setter injection |
| [`core-beans/`](core-beans) | [Spring Bean Scopes: Singleton, Prototype, Request, Session and the Prototype-in-Singleton Trap](https://ankurm.com/spring-bean-scopes-singleton-prototype-request-session-prototype-in-singleton-trap/) and [Spring Bean Lifecycle in Boot 4: @PostConstruct, InitializingBean, SmartLifecycle and Shutdown Order](https://ankurm.com/spring-bean-lifecycle-postconstruct-smartlifecycle-shutdown-order/) | instance counts per scope, five fixes for a prototype inside a singleton, request scope without a proxy failing at start-up, every lifecycle callback in order, `@PostConstruct` running before the proxy exists, `SmartLifecycle` phases measured, and a `SmartLifecycle` at the default phase stopping before Tomcat has drained | | [`core-beans/`](core-beans) | [Spring Bean Scopes: Singleton, Prototype, Request, Session and the Prototype-in-Singleton Trap](https://ankurm.com/spring-bean-scopes-singleton-prototype-request-session-prototype-in-singleton-trap/) [Spring Bean Lifecycle in Boot 4: @PostConstruct, InitializingBean, SmartLifecycle and Shutdown Order](https://ankurm.com/spring-bean-lifecycle-postconstruct-smartlifecycle-shutdown-order/) and [Circular Dependencies in Spring Boot 4: Why Startup Fails and 4 Ways to Fix It](https://ankurm.com/spring-boot-4-circular-dependencies-startup-fails-4-fixes/) | instance counts per scope, five fixes for a prototype inside a singleton, request scope without a proxy failing at start-up, every lifecycle callback in order, `@PostConstruct` running before the proxy exists, `SmartLifecycle` phases measured, a `SmartLifecycle` at the default phase stopping before Tomcat has drained, and the circular-dependency start-up failure reproduced with four fixes |
| [`custom-starter/`](custom-starter) | [Write Your Own Spring Boot 4 Starter: Auto-Configuration, @Conditional and Properties](https://ankurm.com/write-your-own-spring-boot-4-starter-auto-configuration-conditional-properties/) | a starter split into an auto-configuration module and a starter, the `AutoConfiguration.imports` registration, `@ConditionalOn*` conditions, configuration metadata, and tests with `ApplicationContextRunner` |
| [`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 |
| [`kotlin/`](kotlin) | [Kotlin with Spring Boot 4.1: Null Safety, Coroutines, and What Java Developers Get Wrong](https://ankurm.com/) | Kotlin 2.3.21 (Boot 4.1.1's own managed version, not the latest GA), the real `@Configuration class may not be final` and `@Bean method must not be private or final` failures without the allopen/noarg `spring`/`jpa` compiler plugins, `suspend fun`/`Flow<T>` controller methods proven to still resume off a Tomcat thread under plain `spring-boot-starter-webmvc`, a JSpecify `@NullMarked` Java method that still NPEs at runtime because the contract isn't runtime-checked, and `TestRestTemplate` confirmed absent from Boot 4.1's `spring-boot-test` jar |
| [`testing-no-containers/`](testing-no-containers) | [Testing Spring Boot 4 Without Containers: WireMock, MockWebServer and @MockitoBean](https://ankurm.com/) | `@MockBean`'s total removal proven with a real `javac` failure, `@MockitoBean`/`@MockitoSpyBean` context-cache behavior proven with real `org.springframework.test.context.cache` DEBUG log lines, WireMock fault injection against a real `RestClient`, a real `NoClassDefFoundError` from mixing `wiremock` with `wiremock-jetty12` (fixed with `wiremock-standalone`), and MockWebServer's `SocketEffect.ShutdownConnection` for genuinely severing a connection mid-response |
| [`rest-test-client/`](rest-test-client) | [RestTestClient in Spring Framework 7: Testing REST APIs End-to-End (vs MockMvcTester)](https://ankurm.com/resttestclient-spring-framework-7-end-to-end-testing/) | one fluent `RestTestClient` API proven across `bindToController`, `bindTo(mockMvc)`, `bindToApplicationContext` and `bindToServer`; `RestTestClient`, `TestRestTemplate` and `@WebMvcTest` confirmed relocated into new Spring Boot 4 modules by unzipping the real jars; two distinct real failures for the two now-separate `@AutoConfigureRestTestClient`/`@AutoConfigureTestRestTemplate` annotations; and the JDK `HttpClient` Boot picks for `bindToServer` caught transparently decoding gzip and hiding `Content-Encoding`, verified against `curl` and against the older `SimpleClientHttpRequestFactory` |
| [`file-transfer/`](file-transfer) | [File Upload and Download in Spring Boot 4: Multipart, Streaming and S3 Presigned URLs](https://ankurm.com/file-upload-download-spring-boot-4-multipart-streaming-s3-presigned-urls/) | multipart limits enforced by Tomcat (and ignored by `MockMvc`), the client-controlled filename, `Range` downloads, a 200 MB download and 150 MB upload under `-Xmx64m`, and S3 presigned URLs checked against a local SigV4 verifier (not a real S3) |
`core-di` and `core-beans` are the exception: they have **no `docs/` folder**. Their deeper material lives in `core-di`, `core-beans`, `custom-starter`, `core-events`, `multi-datasource`, `i18n`, `aot-cache`, `kotlin`, `testing-no-containers`, `rest-test-client` and `hexagonal` are the exception: they have **no `docs/NN-topic.md` chapter folder**. Their deeper material lives in
collapsible sections inside the articles themselves, and their captured output sits in a top-level `output/` collapsible sections inside the articles themselves. Where their captured output sits is not
directory instead of `docs/output/`. uniform, and is worth checking per module rather than assuming: `core-di`, `core-beans`,
`custom-starter`, `core-events`, `multi-datasource`, `i18n` and `aot-cache` keep it in a
top-level `output/` directory (`hexagonal` is also the one multi-module reactor in this repo,
so its output sits one level deeper still, under each module: `core/output/`,
`persistence-adapter/output/`, and so on) — but `kotlin`, `testing-no-containers` and
`rest-test-client` still use `docs/output/`, same as the chaptered modules, just without the
`docs/NN-topic.md` chapters themselves.
Articles whose text is kept here rather than only on the blog have it under Articles whose text is kept here rather than only on the blog have it under
`<directory>/post/` — `post.md` for the body and `meta.md` for the title, excerpt and `<directory>/post/` — `post.md` for the body and `meta.md` for the title, excerpt and
+6
View File
@@ -0,0 +1,6 @@
target/
*.aot
*.aotconf
*.jsa
work/
work-run.log
+70
View File
@@ -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.
+21
View File
@@ -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"]
+24
View File
@@ -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
+14
View File
@@ -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);
+17
View File
@@ -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 <class>: Unsupported location
4 [warning][aot] Skipping <class>: Unlinked class not supported by AOTClassLinking
4 [warning][aot] Skipping <class>: Failed verification
3 [warning][aot] Skipping <class>: JFR event class
2 [warning][aot] Skipping <class>: Not in loaded state
+138
View File
@@ -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
+9
View File
@@ -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
+152
View File
@@ -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)
+17
View File
@@ -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
+12
View File
@@ -0,0 +1,12 @@
$ curl localhost:18110/ping
ok [HTTP 200]
$ curl -H 'Content-Type: application/json' -d '{"customer":"[email protected]","sku":"ABC-1234","quantity":3}' localhost:18110/orders
{"id":1,"customer":"[email protected]","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":"[email protected]","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]
+62
View File
@@ -0,0 +1,62 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>aot-cache</artifactId>
<version>1.0.0</version>
<name>aot-cache</name>
<description>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</description>
<properties>
<java.version>25</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
</dependencies>
<build>
<finalName>app</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
<profiles>
<profile>
<id>native</id>
<build>
<plugins>
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</profile>
</profiles>
</project>
+15
View File
@@ -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
+14
View File
@@ -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" "$@"; }
+7
View File
@@ -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$//'
+34
View File
@@ -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"
+46
View File
@@ -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":"[email protected]","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}'; }
+80
View File
@@ -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
+11
View File
@@ -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"
+32
View File
@@ -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"
+17
View File
@@ -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
+61
View File
@@ -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"
+59
View File
@@ -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 <class>:/' | sort | uniq -c | sort -rn | head -5
@@ -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);
}
}
@@ -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) {
}
@@ -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) {
}
@@ -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<Order> 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<Order> get(@PathVariable long id) {
return service.find(id).map(ResponseEntity::ok).orElseGet(() -> ResponseEntity.notFound().build());
}
}
@@ -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<Long, Order> 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<Order> find(long id) {
return Optional.ofNullable(orders.get(id));
}
}
@@ -0,0 +1,2 @@
spring.application.name=aot-cache
management.endpoints.web.exposure.include=health
+1
View File
@@ -0,0 +1 @@
target/
+13
View File
@@ -0,0 +1,13 @@
# batch-5-to-6: Spring Batch 5 to 6 on Spring Boot 3.5 to 4.1
Companion code for the ankurm.com post "Spring Batch 5 to 6 Migration Guide".
| Folder | What it is |
|---|---|
| `legacy/` | A one-job app on Boot 3.5.16 / Batch 5.2.6 (JDK 21) using the Batch 5 API |
| `migrated/` | The same app on Boot 4.1.1 / Batch 6.0.5 (JDK 25) using the Batch 6 API |
| `scripts/run-all.sh` | Builds the intermediate stages from `legacy/` and regenerates every file in `output/` |
| `output/` | Transcripts: compile errors, startup failure, deprecation warnings, the silent listener change, and the final run |
Batch 6.0.5 is the latest GA; 6.1.0-M1 and 6.1.0-M2 are milestones and are not tested here.
Tested on 28 September 2026.
+21
View File
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.16</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>batch-legacy</artifactId>
<version>1.0.0</version>
<properties><java.version>21</java.version></properties>
<dependencies>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-batch</artifactId></dependency>
<dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>runtime</scope></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
<dependency><groupId>org.springframework.batch</groupId><artifactId>spring-batch-test</artifactId><scope>test</scope></dependency>
</dependencies>
<build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
</project>
@@ -0,0 +1,14 @@
package com.ankurm.batch;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ConfigurableApplicationContext;
@SpringBootApplication
public class App {
public static void main(String[] args) {
ConfigurableApplicationContext ctx = SpringApplication.run(App.class, args);
ctx.getBean(JobRunner.class).run();
ctx.close();
}
}
@@ -0,0 +1,55 @@
package com.ankurm.batch;
import java.util.List;
import org.springframework.batch.core.ChunkListener;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.scope.context.ChunkContext;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.batch.item.ItemReader;
import org.springframework.batch.item.ItemWriter;
import org.springframework.batch.item.support.ListItemReader;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;
@Configuration
public class BatchConfig {
@Bean
ItemReader<Integer> reader() {
return new ListItemReader<>(List.of(1, 2, 3, 4, 5, 6, 7));
}
@Bean
ItemWriter<String> writer() {
return chunk -> System.out.println("WRITE " + chunk.getItems());
}
@Bean
ChunkListener chunkListener() {
return new ChunkListener() {
@Override public void afterChunk(ChunkContext context) {
System.out.println("CHUNK DONE, read so far=" + context.getStepContext().getStepExecution().getReadCount());
}
};
}
@Bean
Step squareStep(JobRepository repo, PlatformTransactionManager tx, ChunkListener listener) {
return new StepBuilder("squareStep", repo)
.<Integer, String>chunk(3, tx)
.reader(reader())
.processor(i -> "sq" + (i * i))
.writer(writer())
.listener(listener)
.build();
}
@Bean
Job squareJob(JobRepository repo, Step squareStep) {
return new JobBuilder("squareJob", repo).start(squareStep).build();
}
}
@@ -0,0 +1,33 @@
package com.ankurm.batch;
import org.springframework.batch.core.BatchStatus;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.JobExecution;
import org.springframework.batch.core.JobParameters;
import org.springframework.batch.core.JobParametersBuilder;
import org.springframework.batch.core.explore.JobExplorer;
import org.springframework.batch.core.launch.JobLauncher;
import org.springframework.stereotype.Component;
@Component
public class JobRunner {
private final JobLauncher launcher;
private final JobExplorer explorer;
private final Job job;
public JobRunner(JobLauncher launcher, JobExplorer explorer, Job squareJob) {
this.launcher = launcher; this.explorer = explorer; this.job = squareJob;
}
public void run() {
try {
JobParameters params = new JobParametersBuilder().addLong("run.id", 1L).toJobParameters();
JobExecution exec = launcher.run(job, params);
System.out.println("STATUS " + exec.getStatus() + " exit=" + exec.getExitStatus().getExitCode());
System.out.println("INSTANCES " + explorer.getJobInstanceCount("squareJob"));
System.out.println("DONE " + (exec.getStatus() == BatchStatus.COMPLETED));
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
}
@@ -0,0 +1,3 @@
spring.batch.job.enabled=false
spring.datasource.url=jdbc:h2:mem:legacy;DB_CLOSE_DELAY=-1
spring.batch.jdbc.initialize-schema=always
@@ -0,0 +1,23 @@
package com.ankurm.batch;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import org.springframework.batch.core.BatchStatus;
import org.springframework.batch.core.JobExecution;
import org.springframework.batch.test.JobLauncherTestUtils;
import org.springframework.batch.test.context.SpringBatchTest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
@SpringBatchTest
class JobTest {
@Autowired JobLauncherTestUtils utils;
@Test
void jobCompletes() throws Exception {
JobExecution exec = utils.launchJob();
assertThat(exec.getStatus()).isEqualTo(BatchStatus.COMPLETED);
}
}
+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>batch-migrated</artifactId>
<version>1.0.0</version>
<properties><java.version>25</java.version></properties>
<dependencies>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-batch-jdbc</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-jdbc</artifactId></dependency>
<dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>runtime</scope></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
<dependency><groupId>org.springframework.batch</groupId><artifactId>spring-batch-test</artifactId><scope>test</scope></dependency>
</dependencies>
<build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
</project>
@@ -0,0 +1,14 @@
package com.ankurm.batch;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ConfigurableApplicationContext;
@SpringBootApplication
public class App {
public static void main(String[] args) {
ConfigurableApplicationContext ctx = SpringApplication.run(App.class, args);
ctx.getBean(JobRunner.class).run();
ctx.close();
}
}
@@ -0,0 +1,56 @@
package com.ankurm.batch;
import java.util.List;
import org.springframework.batch.core.listener.ChunkListener;
import org.springframework.batch.core.job.Job;
import org.springframework.batch.core.step.Step;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.batch.infrastructure.item.Chunk;
import org.springframework.batch.infrastructure.item.ItemReader;
import org.springframework.batch.infrastructure.item.ItemWriter;
import org.springframework.batch.infrastructure.item.support.ListItemReader;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;
@Configuration
public class BatchConfig {
@Bean
ItemReader<Integer> reader() {
return new ListItemReader<>(List.of(1, 2, 3, 4, 5, 6, 7));
}
@Bean
ItemWriter<String> writer() {
return chunk -> System.out.println("WRITE " + chunk.getItems());
}
@Bean
ChunkListener<Integer, String> chunkListener() {
return new ChunkListener<>() {
@Override public void afterChunk(Chunk<String> chunk) {
System.out.println("CHUNK DONE, items in chunk=" + chunk.size());
}
};
}
@Bean
Step squareStep(JobRepository repo, PlatformTransactionManager tx, ChunkListener<Integer, String> listener) {
return new StepBuilder("squareStep", repo)
.<Integer, String>chunk(3)
.transactionManager(tx)
.reader(reader())
.processor(i -> "sq" + (i * i))
.writer(writer())
.listener(listener)
.build();
}
@Bean
Job squareJob(JobRepository repo, Step squareStep) {
return new JobBuilder("squareJob", repo).start(squareStep).build();
}
}
@@ -0,0 +1,33 @@
package com.ankurm.batch;
import org.springframework.batch.core.BatchStatus;
import org.springframework.batch.core.job.Job;
import org.springframework.batch.core.job.JobExecution;
import org.springframework.batch.core.job.parameters.JobParameters;
import org.springframework.batch.core.job.parameters.JobParametersBuilder;
import org.springframework.batch.core.launch.JobOperator;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.stereotype.Component;
@Component
public class JobRunner {
private final JobOperator operator;
private final JobRepository repository;
private final Job job;
public JobRunner(JobOperator operator, JobRepository repository, Job squareJob) {
this.operator = operator; this.repository = repository; this.job = squareJob;
}
public void run() {
try {
JobParameters params = new JobParametersBuilder().addLong("run.id", 1L).toJobParameters();
JobExecution exec = operator.start(job, params);
System.out.println("STATUS " + exec.getStatus() + " exit=" + exec.getExitStatus().getExitCode());
System.out.println("INSTANCES " + repository.getJobInstanceCount("squareJob"));
System.out.println("DONE " + (exec.getStatus() == BatchStatus.COMPLETED));
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
}
@@ -0,0 +1,3 @@
spring.batch.job.enabled=false
spring.datasource.url=jdbc:h2:mem:legacy;DB_CLOSE_DELAY=-1
spring.batch.jdbc.initialize-schema=always
@@ -0,0 +1,23 @@
package com.ankurm.batch;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import org.springframework.batch.core.BatchStatus;
import org.springframework.batch.core.job.JobExecution;
import org.springframework.batch.test.JobOperatorTestUtils;
import org.springframework.batch.test.context.SpringBatchTest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
@SpringBatchTest
class JobTest {
@Autowired JobOperatorTestUtils utils;
@Test
void jobCompletes() throws Exception {
JobExecution exec = utils.startJob();
assertThat(exec.getStatus()).isEqualTo(BatchStatus.COMPLETED);
}
}
+15
View File
@@ -0,0 +1,15 @@
$ mvn test # Boot 3.5.16, Batch 5.2.6, JDK 21
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 3.750 s -- in com.ankurm.batch.JobTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
$ java -jar target/batch-legacy-1.0.0.jar
WRITE [sq1, sq4, sq9]
CHUNK DONE, read so far=3
WRITE [sq16, sq25, sq36]
CHUNK DONE, read so far=6
WRITE [sq49]
CHUNK DONE, read so far=7
STATUS COMPLETED exit=COMPLETED
INSTANCES 1
DONE true
@@ -0,0 +1,10 @@
$ mvn compile # legacy sources, only the Boot version changed to 4.1.1 (Batch 6.0.5)
compile error lines reported: 56
packages that no longer exist:
package org.springframework.batch.core.explore does not exist
package org.springframework.batch.item does not exist
package org.springframework.batch.item.support does not exist
classes that moved:
ChunkListener ItemReader ItemWriter Job JobExecution JobExplorer JobParameters JobParametersBuilder ListItemReader Step
@@ -0,0 +1,19 @@
$ mvn package # imports ported to the new packages, dependencies unchanged (spring-boot-starter-batch)
[WARNING] s2/src/main/java/com/ankurm/batch/BatchConfig.java:[34,35] afterChunk(org.springframework.batch.core.scope.context.ChunkContext) in org.springframework.batch.core.listener.ChunkListener has been deprecated and marked for removal
[WARNING] s2/src/main/java/com/ankurm/batch/BatchConfig.java:[43,17] <I,O>chunk(int,org.springframework.transaction.PlatformTransactionManager) in org.springframework.batch.core.step.builder.StepBuilder has been deprecated and marked for removal
[WARNING] s2/src/main/java/com/ankurm/batch/JobRunner.java:[14,19] org.springframework.batch.core.launch.JobLauncher in org.springframework.batch.core.launch has been deprecated and marked for removal
[WARNING] s2/src/main/java/com/ankurm/batch/JobRunner.java:[15,19] org.springframework.batch.core.repository.explore.JobExplorer in org.springframework.batch.core.repository.explore has been deprecated and marked for removal
[WARNING] s2/src/main/java/com/ankurm/batch/JobRunner.java:[18,22] org.springframework.batch.core.launch.JobLauncher in org.springframework.batch.core.launch has been deprecated and marked for removal
[WARNING] s2/src/main/java/com/ankurm/batch/JobRunner.java:[18,44] org.springframework.batch.core.repository.explore.JobExplorer in org.springframework.batch.core.repository.explore has been deprecated and marked for removal
[WARNING] s2/src/test/java/com/ankurm/batch/JobTest.java:[16,16] org.springframework.batch.test.JobLauncherTestUtils in org.springframework.batch.test has been deprecated and marked for removal
[WARNING] s2/src/test/java/com/ankurm/batch/JobTest.java:[20,34] launchJob() in org.springframework.batch.test.JobLauncherTestUtils has been deprecated and marked for removal
[INFO] BUILD SUCCESS
$ java -jar target/batch-legacy-1.0.0.jar
APPLICATION FAILED TO START
***************************
Description:
Parameter 1 of method squareStep in com.ankurm.batch.BatchConfig required a bean of type 'org.springframework.transaction.PlatformTransactionManager' that could not be found.
@@ -0,0 +1,23 @@
$ mvn package # + spring-boot-starter-batch-jdbc and spring-boot-starter-jdbc
[WARNING] s3/src/main/java/com/ankurm/batch/BatchConfig.java:[34,35] afterChunk(org.springframework.batch.core.scope.context.ChunkContext) in org.springframework.batch.core.listener.ChunkListener has been deprecated and marked for removal
[WARNING] s3/src/main/java/com/ankurm/batch/BatchConfig.java:[43,17] <I,O>chunk(int,org.springframework.transaction.PlatformTransactionManager) in org.springframework.batch.core.step.builder.StepBuilder has been deprecated and marked for removal
[WARNING] s3/src/main/java/com/ankurm/batch/JobRunner.java:[14,19] org.springframework.batch.core.launch.JobLauncher in org.springframework.batch.core.launch has been deprecated and marked for removal
[WARNING] s3/src/main/java/com/ankurm/batch/JobRunner.java:[15,19] org.springframework.batch.core.repository.explore.JobExplorer in org.springframework.batch.core.repository.explore has been deprecated and marked for removal
[WARNING] s3/src/main/java/com/ankurm/batch/JobRunner.java:[18,22] org.springframework.batch.core.launch.JobLauncher in org.springframework.batch.core.launch has been deprecated and marked for removal
[WARNING] s3/src/main/java/com/ankurm/batch/JobRunner.java:[18,44] org.springframework.batch.core.repository.explore.JobExplorer in org.springframework.batch.core.repository.explore has been deprecated and marked for removal
[WARNING] s3/src/test/java/com/ankurm/batch/JobTest.java:[16,16] org.springframework.batch.test.JobLauncherTestUtils in org.springframework.batch.test has been deprecated and marked for removal
[WARNING] s3/src/test/java/com/ankurm/batch/JobTest.java:[20,34] launchJob() in org.springframework.batch.test.JobLauncherTestUtils has been deprecated and marked for removal
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 4.571 s -- in com.ankurm.batch.JobTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
$ java -jar target/batch-legacy-1.0.0.jar
WRITE [sq1, sq4, sq9]
CHUNK DONE, read so far=3
WRITE [sq16, sq25, sq36]
CHUNK DONE, read so far=6
WRITE [sq49]
CHUNK DONE, read so far=7
STATUS COMPLETED exit=COMPLETED
INSTANCES 1
DONE true
@@ -0,0 +1,17 @@
$ mvn package # chunk(3, tx) replaced by chunk(3).transactionManager(tx); listener untouched
[WARNING] s4/src/main/java/com/ankurm/batch/BatchConfig.java:[34,35] afterChunk(org.springframework.batch.core.scope.context.ChunkContext) in org.springframework.batch.core.listener.ChunkListener has been deprecated and marked for removal
[WARNING] s4/src/main/java/com/ankurm/batch/JobRunner.java:[14,19] org.springframework.batch.core.launch.JobLauncher in org.springframework.batch.core.launch has been deprecated and marked for removal
[WARNING] s4/src/main/java/com/ankurm/batch/JobRunner.java:[15,19] org.springframework.batch.core.repository.explore.JobExplorer in org.springframework.batch.core.repository.explore has been deprecated and marked for removal
[WARNING] s4/src/main/java/com/ankurm/batch/JobRunner.java:[18,22] org.springframework.batch.core.launch.JobLauncher in org.springframework.batch.core.launch has been deprecated and marked for removal
[WARNING] s4/src/main/java/com/ankurm/batch/JobRunner.java:[18,44] org.springframework.batch.core.repository.explore.JobExplorer in org.springframework.batch.core.repository.explore has been deprecated and marked for removal
[WARNING] s4/src/test/java/com/ankurm/batch/JobTest.java:[16,16] org.springframework.batch.test.JobLauncherTestUtils in org.springframework.batch.test has been deprecated and marked for removal
[WARNING] s4/src/test/java/com/ankurm/batch/JobTest.java:[20,34] launchJob() in org.springframework.batch.test.JobLauncherTestUtils has been deprecated and marked for removal
[INFO] BUILD SUCCESS
$ java -jar target/batch-legacy-1.0.0.jar
WRITE [sq1, sq4, sq9]
WRITE [sq16, sq25, sq36]
WRITE [sq49]
STATUS COMPLETED exit=COMPLETED
INSTANCES 1
DONE true
+15
View File
@@ -0,0 +1,15 @@
$ mvn package # final migrated project
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 4.029 s -- in com.ankurm.batch.JobTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
$ java -jar target/batch-migrated-1.0.0.jar
WRITE [sq1, sq4, sq9]
CHUNK DONE, items in chunk=3
WRITE [sq16, sq25, sq36]
CHUNK DONE, items in chunk=3
WRITE [sq49]
CHUNK DONE, items in chunk=1
STATUS COMPLETED exit=COMPLETED
INSTANCES 1
DONE true
+13
View File
@@ -0,0 +1,13 @@
$ javap (Batch 6.0.5 jars), selected lines
public interface core.launch.JobOperator extends core.launch.JobLauncher {
public interface core.repository.JobRepository extends core.repository.explore.JobExplorer {
public class test.JobOperatorTestUtils extends test.JobLauncherTestUtils {
public <I, O> core.step.builder.SimpleStepBuilder<I, O> chunk(int, PlatformTransactionManager);
public <I, O> core.step.builder.ChunkOrientedStepBuilder<I, O> chunk(int);
public <I, O> core.step.builder.SimpleStepBuilder<I, O> chunk(infrastructure.repeat.CompletionPolicy, PlatformTransactionManager);
public default void beforeChunk(core.scope.context.ChunkContext);
public default void afterChunk(core.scope.context.ChunkContext);
public default void afterChunkError(core.scope.context.ChunkContext);
public default void beforeChunk(infrastructure.item.Chunk<I>);
public default void afterChunk(infrastructure.item.Chunk<O>);
public default void onChunkError(java.lang.Exception, infrastructure.item.Chunk<O>);
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
# Regenerates output/*.txt. Needs Maven, JDK 21 (J21) and JDK 25 (J25); network for Maven Central.
set -u
HERE=$(cd "$(dirname "$0")/.." && pwd); OUT=$HERE/output; W=${WORK:-/tmp/b56-work}
J21=${J21:-/usr/lib/jvm/java-21-openjdk-amd64}; J25=${J25:-/tmp/tools/jdk-25.0.4.1+1}; MVN=${MVN:-mvn}
mkdir -p "$OUT"; rm -rf "$W"; mkdir -p "$W"
mv_() { local jh=$1 d=$2; shift 2; (cd "$d" && JAVA_HOME=$jh $MVN -B -Dmaven.compiler.showDeprecation=true "$@" 2>&1 | grep -v JAVA_TOOL_OPTIONS | sed "s#$W/##g"); }
runjar() { local jh=$1 jar=$2; JAVA_HOME=$jh $jh/bin/java -jar "$jar" 2>&1 | grep -E '^(WRITE|CHUNK|STATUS|INSTANCES|DONE)'; }
stage() { rm -rf "$W/$1"; cp -r "$HERE/$2" "$W/$1"; rm -rf "$W/$1/target"; }
PORT_IMPORTS=$(cat <<'PY'
import glob,sys
m={'org.springframework.batch.core.ChunkListener':'org.springframework.batch.core.listener.ChunkListener',
'org.springframework.batch.core.Job;':'org.springframework.batch.core.job.Job;',
'org.springframework.batch.core.Step;':'org.springframework.batch.core.step.Step;',
'org.springframework.batch.core.JobExecution;':'org.springframework.batch.core.job.JobExecution;',
'org.springframework.batch.core.JobParameters;':'org.springframework.batch.core.job.parameters.JobParameters;',
'org.springframework.batch.core.JobParametersBuilder':'org.springframework.batch.core.job.parameters.JobParametersBuilder',
'org.springframework.batch.core.explore.JobExplorer':'org.springframework.batch.core.repository.explore.JobExplorer',
'org.springframework.batch.item.':'org.springframework.batch.infrastructure.item.'}
for f in glob.glob(sys.argv[1]+'/src/**/*.java',recursive=True):
s=open(f).read()
for a,b in m.items(): s=s.replace(a,b)
open(f,'w').write(s)
PY
)
# 01 legacy: Boot 3.5.16 / Batch 5.2.6
stage s0 legacy
{ echo '$ mvn test # Boot 3.5.16, Batch 5.2.6, JDK 21'; mv_ "$J21" "$W/s0" package | grep -E 'Tests run:|BUILD'
echo; echo '$ java -jar target/batch-legacy-1.0.0.jar'; runjar "$J21" "$W/s0/target/batch-legacy-1.0.0.jar"; } > "$OUT/01-legacy-batch5.txt"
# 02 same sources, Boot 4.1.1
stage s1 legacy; sed -i 's/3.5.16/4.1.1/; s/<java.version>21/<java.version>25/' "$W/s1/pom.xml"
{ echo '$ mvn compile # legacy sources, only the Boot version changed to 4.1.1 (Batch 6.0.5)'
mv_ "$J25" "$W/s1" compile > "$W/s1.log"
echo "compile error lines reported: $(grep -c '^\[ERROR\] .*\.java' "$W/s1.log")"; echo; echo 'packages that no longer exist:'
grep -oE 'package [a-z.]+ does not exist' "$W/s1.log" | sort -u; echo; echo 'classes that moved:'
grep -oE 'symbol: +class [A-Za-z]+' "$W/s1.log" | awk '{print $3}' | sort -u | tr '\n' ' '; echo; } > "$OUT/02-compile-errors-after-bump.txt"
# 03 imports ported, dependencies untouched
cp -r "$W/s1" "$W/s2"; rm -rf "$W/s2/target"; python3 -c "$PORT_IMPORTS" "$W/s2"
{ echo '$ mvn package # imports ported to the new packages, dependencies unchanged (spring-boot-starter-batch)'
mv_ "$J25" "$W/s2" -DskipTests package | grep -E 'WARNING.*\.java|BUILD|ERROR.*\.java'
echo; echo '$ java -jar target/batch-legacy-1.0.0.jar'
JAVA_HOME=$J25 $J25/bin/java -jar "$W/s2/target/batch-legacy-1.0.0.jar" 2>&1 | grep -A6 'APPLICATION FAILED'; } > "$OUT/03-imports-ported-no-transaction-manager.txt"
# 04 + JDBC starter
cp -r "$W/s2" "$W/s3"; rm -rf "$W/s3/target"; python3 - "$W/s3/pom.xml" <<'PY'
import sys; p=sys.argv[1]; s=open(p).read()
s=s.replace('<artifactId>spring-boot-starter-batch</artifactId></dependency>','<artifactId>spring-boot-starter-batch-jdbc</artifactId></dependency>\n <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-jdbc</artifactId></dependency>')
open(p,'w').write(s)
PY
{ echo '$ mvn package # + spring-boot-starter-batch-jdbc and spring-boot-starter-jdbc'
mv_ "$J25" "$W/s3" package | grep -E 'WARNING.*\.java|Tests run:|BUILD'
echo; echo '$ java -jar target/batch-legacy-1.0.0.jar'; runjar "$J25" "$W/s3/target/batch-legacy-1.0.0.jar"; } > "$OUT/04-jdbc-starter-added.txt"
# 05 new chunk API but old listener
cp -r "$W/s3" "$W/s4"; rm -rf "$W/s4/target"; sed -i 's/\.<Integer, String>chunk(3, tx)/.<Integer, String>chunk(3)\n .transactionManager(tx)/' "$W/s4/src/main/java/com/ankurm/batch/BatchConfig.java"
{ echo '$ mvn package # chunk(3, tx) replaced by chunk(3).transactionManager(tx); listener untouched'
mv_ "$J25" "$W/s4" -DskipTests package | grep -E 'WARNING.*\.java|BUILD|ERROR.*\.java'
echo; echo '$ java -jar target/batch-legacy-1.0.0.jar'; runjar "$J25" "$W/s4/target/batch-legacy-1.0.0.jar"; } > "$OUT/05-new-chunk-api-listener-silent.txt"
# 06 final migrated
stage m migrated
{ echo '$ mvn package # final migrated project'
mv_ "$J25" "$W/m" package | grep -E 'WARNING.*\.java|Tests run:|BUILD'
echo; echo '$ java -jar target/batch-migrated-1.0.0.jar'; runjar "$J25" "$W/m/target/batch-migrated-1.0.0.jar"; } > "$OUT/06-migrated.txt"
# 07 API facts from the jars
M2=${M2:-$HOME/.m2/repository/org/springframework/batch}
CP=$M2/spring-batch-core/6.0.5/spring-batch-core-6.0.5.jar:$M2/spring-batch-infrastructure/6.0.5/spring-batch-infrastructure-6.0.5.jar:$M2/spring-batch-test/6.0.5/spring-batch-test-6.0.5.jar
{ echo '$ javap (Batch 6.0.5 jars), selected lines'
for c in org.springframework.batch.core.launch.JobOperator org.springframework.batch.core.repository.JobRepository org.springframework.batch.test.JobOperatorTestUtils; do env -u JAVA_TOOL_OPTIONS $J25/bin/javap -cp $CP $c 2>&1 | grep -E '^public (interface|class)' | sed 's/org\.springframework\.batch\.//g'; done
env -u JAVA_TOOL_OPTIONS $J25/bin/javap -cp $CP org.springframework.batch.core.step.builder.StepBuilder 2>&1 | grep -E ' chunk\(' | sed 's/org\.springframework\.batch\.//g; s/org\.springframework\.transaction\.//g; s/org\.springframework\.//g'
env -u JAVA_TOOL_OPTIONS $J25/bin/javap -cp $CP org.springframework.batch.core.listener.ChunkListener 2>&1 | grep -E 'Chunk(Context|<)' | sed 's/org\.springframework\.batch\.//g'; } > "$OUT/07-api-facts.txt"
echo done
+16
View File
@@ -0,0 +1,16 @@
# boot42-preview
Companion module for the ankurm.com article *Spring Boot 4.2 Preview: AMQP 1.0, Buildpack Image Cache and What to Test Now*.
Everything runs against **4.2.0-M2** (a milestone, not GA) and, where a comparison matters, against **4.1.1** (GA).
| Path | What it is |
|---|---|
| `pom.xml` / `pom-41.xml` | the same project on 4.2.0-M2 and on 4.1.1 (a `${revision}` parent does not resolve from a repository, hence two files) |
| `src/test/.../ForwardedHeadersTest` | what the container believes under each forwarded-header strategy |
| `src/test/.../BindingOrderTest` | Set and Map iteration order from a properties file |
| `src/broken/UsesRestTemplate.java` | not built; compiled by the facts script to capture the deprecation warnings |
| `src/main/.../RestClientReplacement` | the replacement, compiled clean |
| `examples/build-image-cache-fragment.xml` | **not executed** (needs Docker and a registry) |
| `output/` | every transcript quoted in the article; `./scripts/run-all.sh` regenerates them |
There is deliberately no `docs/` folder: the explanations live in the article.
@@ -0,0 +1,15 @@
<!-- NOT EXECUTED: needs a Docker daemon and a registry. Element names come from the setters javap shows in
output/04-buildpack-cache-classes.txt (CacheInfo.setImage, ImageCacheInfo.setName). -->
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<image>
<buildCache>
<image>
<name>registry.example.com/team/demo-build-cache:latest</name>
</image>
</buildCache>
</image>
</configuration>
</plugin>
@@ -0,0 +1,10 @@
# 01-forwarded-headers (Spring Boot 4.1.1, JDK 25)
none/x-forwarded scheme=http secure=false host=localhost port=<random>
none/rfc7239 scheme=http secure=false host=localhost port=<random>
native/x-forwarded scheme=https secure=true host=shop.example.com port=443
native/rfc7239 scheme=http secure=false host=localhost port=<random>
framework/x-forwarded scheme=https secure=true host=shop.example.com port=443
framework/rfc7239 scheme=https secure=true host=shop.example.com port=443
framework+standard/x-forwarded scheme=https secure=true host=shop.example.com port=443
framework+standard/rfc7239 scheme=https secure=true host=shop.example.com port=443
header-format=forwarded -> started (property ignored)
@@ -0,0 +1,10 @@
# 01-forwarded-headers (Spring Boot 4.2.0-M2, JDK 25)
none/x-forwarded scheme=http secure=false host=localhost port=<random>
none/rfc7239 scheme=http secure=false host=localhost port=<random>
native/x-forwarded scheme=https secure=true host=shop.example.com port=443
native/rfc7239 scheme=http secure=false host=localhost port=<random>
framework/x-forwarded scheme=https secure=true host=shop.example.com port=443
framework/rfc7239 scheme=http secure=false host=localhost port=<random>
framework+standard/x-forwarded scheme=http secure=false host=localhost port=<random>
framework+standard/rfc7239 scheme=https secure=true host=shop.example.com port=443
header-format=forwarded -> IllegalArgumentException: No enum constant org.springframework.boot.webmvc.autoconfigure.WebMvcProperties.HeaderFormat.forwarded
@@ -0,0 +1,6 @@
# 02-set-and-map-binding-order (Spring Boot 4.1.1, JDK 25)
file order : zulu, alpha, mike, bravo, yankee, charlie
Set class : java.util.LinkedHashSet
Set order : [zulu, alpha, mike, bravo, yankee, charlie]
Map class : java.util.LinkedHashMap
Map key order : [zulu, alpha, mike, bravo, yankee, charlie]
@@ -0,0 +1,6 @@
# 02-set-and-map-binding-order (Spring Boot 4.2.0-M2, JDK 25)
file order : zulu, alpha, mike, bravo, yankee, charlie
Set class : java.util.LinkedHashSet
Set order : [zulu, alpha, mike, bravo, yankee, charlie]
Map class : java.util.LinkedHashMap
Map key order : [zulu, alpha, mike, bravo, yankee, charlie]
@@ -0,0 +1,24 @@
# 03-amqp-starters (POM descriptions and contents straight from Maven Central)
spring-boot-starter-amqp 4.1.1 HTTP 200 "Starter for using Spring AMQP and Rabbit MQ" -> spring-boot-amqp
spring-boot-starter-amqp 4.2.0-M2 HTTP 200 "Starter for generic AMQP 1.0 support" -> spring-boot-amqp
spring-boot-starter-rabbitmq 4.1.1 HTTP 404 (does not exist)
spring-boot-starter-rabbitmq 4.2.0-M2 HTTP 200 "Starter for using RabbitMQ messaging and streaming broker" -> spring-boot-rabbitmq
spring-boot-starter-amqp-rabbitmq 4.1.1 HTTP 404 (does not exist)
spring-boot-starter-amqp-rabbitmq 4.2.0-M2 HTTP 200 "Starter for using Spring AMQP with Rabbit MQ over AMQP 1.0 protocol" -> spring-boot-amqp-rabbitmq
# auto-configuration each module registers (AutoConfiguration.imports)
--- spring-boot-amqp 4.1.1
org.springframework.boot.amqp.autoconfigure.RabbitAutoConfiguration
org.springframework.boot.amqp.autoconfigure.health.RabbitHealthContributorAutoConfiguration
org.springframework.boot.amqp.autoconfigure.metrics.RabbitMetricsAutoConfiguration
--- spring-boot-amqp 4.2.0-M2
org.springframework.boot.amqp.autoconfigure.AmqpAutoConfiguration
--- spring-boot-rabbitmq 4.2.0-M2
org.springframework.boot.rabbitmq.autoconfigure.RabbitAutoConfiguration
org.springframework.boot.rabbitmq.autoconfigure.health.RabbitHealthContributorAutoConfiguration
org.springframework.boot.rabbitmq.autoconfigure.metrics.RabbitMetricsAutoConfiguration
--- spring-boot-amqp-rabbitmq 4.2.0-M2
org.springframework.boot.amqp.rabbitmq.autoconfigure.AmqpRabbitAutoConfiguration
org.springframework.boot.amqp.rabbitmq.autoconfigure.health.AmqpRabbitHealthContributorAutoConfiguration
@@ -0,0 +1,50 @@
# 04-buildpack-cache-classes (javap, spring-boot-buildpack-platform and spring-boot-maven-plugin)
--- buildpack-platform 4.1.1: classes matching Cache
Cache$Bind.class
Cache$Format.class
Cache$Volume.class
Cache.class
--- buildpack-platform 4.2.0-M2: classes matching Cache
Cache.class
ImageCache.class
LocalCache$Bind.class
LocalCache$Volume.class
LocalCache.class
--- javap Cache (4.2.0-M2)
Compiled from "Cache.java"
public interface Cache {
public default LocalCache$Volume getVolume();
public default LocalCache$Bind getBind();
public default ImageCache getImage();
public static LocalCache volume(java.lang.String);
public static LocalCache volume(VolumeName);
public static LocalCache bind(java.lang.String);
public static ImageCache image(java.lang.String);
}
--- javap org.springframework.boot.maven.CacheInfo (4.2.0-M2)
Compiled from "CacheInfo.java"
public class CacheInfo {
public CacheInfo();
public void setVolume(LocalCacheInfo$VolumeCacheInfo);
public void setBind(LocalCacheInfo$BindCacheInfo);
public void setImage(CacheInfo$ImageCacheInfo);
Cache asCache();
static CacheInfo fromVolume(LocalCacheInfo$VolumeCacheInfo);
static CacheInfo fromBind(LocalCacheInfo$BindCacheInfo);
static CacheInfo fromImage(CacheInfo$ImageCacheInfo);
}
--- javap org.springframework.boot.maven.LocalCacheInfo (4.2.0-M2)
Compiled from "LocalCacheInfo.java"
public class LocalCacheInfo {
public LocalCacheInfo();
LocalCacheInfo(LocalCache);
public void setVolume(LocalCacheInfo$VolumeCacheInfo);
public void setBind(LocalCacheInfo$BindCacheInfo);
LocalCache asCache();
static LocalCacheInfo fromVolume(LocalCacheInfo$VolumeCacheInfo);
static LocalCacheInfo fromBind(LocalCacheInfo$BindCacheInfo);
}
--- javap -p Image, fields mentioning cache/workspace (4.2.0-M2)
LocalCacheInfo buildWorkspace;
CacheInfo buildCache;
LocalCacheInfo launchCache;
@@ -0,0 +1,22 @@
# 05-resttemplate-deprecations (javac -Xlint:deprecation on src/broken/UsesRestTemplate.java)
--- Spring Boot 4.1.1
--- Spring Boot 4.2.0-M2
UsesRestTemplate.java:8: warning: [removal] RestTemplate in org.springframework.web.client has been deprecated and marked for removal
RestTemplate viaBuilder(RestTemplateBuilder builder) {
^
UsesRestTemplate.java:8: warning: [removal] RestTemplateBuilder in org.springframework.boot.restclient has been deprecated and marked for removal
RestTemplate viaBuilder(RestTemplateBuilder builder) {
^
UsesRestTemplate.java:12: warning: [removal] TestRestTemplate in org.springframework.boot.resttestclient has been deprecated and marked for removal
TestRestTemplate viaTest(TestRestTemplate t) {
^
UsesRestTemplate.java:12: warning: [removal] TestRestTemplate in org.springframework.boot.resttestclient has been deprecated and marked for removal
TestRestTemplate viaTest(TestRestTemplate t) {
^
UsesRestTemplate.java:16: warning: [removal] RestTemplate in org.springframework.web.client has been deprecated and marked for removal
RestTemplate plain() {
^
UsesRestTemplate.java:17: warning: [removal] RestTemplate in org.springframework.web.client has been deprecated and marked for removal
return new RestTemplate();
^
6 warnings
@@ -0,0 +1,2 @@
# 06-restclient-replacement (javac -Xlint:deprecation on the RestClient version, Spring Boot 4.2.0-M2)
javac finished; lines above this one are warnings (none means a clean compile)
+57
View File
@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>boot42-preview-on-41</artifactId>
<version>1.0.0</version>
<name>boot42-preview</name>
<description>Spring Boot 4.2 milestone preview: what to test now, run against 4.2.0-M2 and 4.1.1</description>
<properties>
<java.version>25</java.version>
<boot.label>4.1.1</boot.label>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-restclient</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<systemPropertyVariables>
<boot.label>${boot.label}</boot.label>
</systemPropertyVariables>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
+57
View File
@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.2.0-M2</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>boot42-preview</artifactId>
<version>1.0.0</version>
<name>boot42-preview</name>
<description>Spring Boot 4.2 milestone preview: what to test now, run against 4.2.0-M2 and 4.1.1</description>
<properties>
<java.version>25</java.version>
<boot.label>4.2.0-M2</boot.label>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-restclient</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<systemPropertyVariables>
<boot.label>${boot.label}</boot.label>
</systemPropertyVariables>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env bash
# Facts read from jars and POMs on Maven Central: nothing here needs a running application.
set -euo pipefail
cd "$(dirname "$0")/.."
M="$HOME/.m2/repository/org/springframework/boot"
CEN=https://repo1.maven.org/maven2/org/springframework/boot
W=$(mktemp -d)
for v in 4.1.1 4.2.0-M2; do for a in spring-boot-maven-plugin spring-boot-buildpack-platform spring-boot-amqp spring-boot-amqp-rabbitmq spring-boot-rabbitmq; do
mvn -B -q dependency:get -Dartifact=org.springframework.boot:$a:$v >/dev/null 2>&1 || true
done; done
{
echo "# 03-amqp-starters (POM descriptions and contents straight from Maven Central)"
for a in spring-boot-starter-amqp spring-boot-starter-rabbitmq spring-boot-starter-amqp-rabbitmq; do
for v in 4.1.1 4.2.0-M2; do
for try in 1 2 3 4 5; do
code=$(curl -s -o "$W/p.xml" -w '%{http_code}' "$CEN/$a/$v/$a-$v.pom")
[ "$code" = 429 ] || break
sleep $((try * 3))
done
if [ "$code" = 200 ]; then
d=$(grep -m1 '<description>' "$W/p.xml" | sed 's/ *<[^>]*>//g')
dep=$(grep -E '<artifactId>spring-boot-(amqp|rabbitmq|amqp-rabbitmq)</artifactId>' "$W/p.xml" | sed 's/ *<[^>]*>//g' | paste -sd, -)
printf '%-34s %-10s HTTP %s "%s" -> %s\n' "$a" "$v" "$code" "$d" "$dep"
else
printf '%-34s %-10s HTTP %s (does not exist)\n' "$a" "$v" "$code"
fi
done
done
echo
echo "# auto-configuration each module registers (AutoConfiguration.imports)"
for a in spring-boot-amqp spring-boot-rabbitmq spring-boot-amqp-rabbitmq; do
for v in 4.1.1 4.2.0-M2; do
j="$M/$a/$v/$a-$v.jar"
if [ -f "$j" ]; then
echo "--- $a $v"; unzip -p "$j" META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 2>/dev/null | sed 's/^/ /' || true; echo
fi
done
done
} > output/03-amqp-starters.txt
{
echo "# 04-buildpack-cache-classes (javap, spring-boot-buildpack-platform and spring-boot-maven-plugin)"
for v in 4.1.1 4.2.0-M2; do
echo "--- buildpack-platform $v: classes matching Cache"
unzip -l "$M/spring-boot-buildpack-platform/$v/spring-boot-buildpack-platform-$v.jar" | awk '{print $4}' | grep -E 'build/.*Cache' | sed 's#.*/build/# #'
done
mkdir -p "$W/bp"
unzip -q -o "$M/spring-boot-buildpack-platform/4.2.0-M2/spring-boot-buildpack-platform-4.2.0-M2.jar" 'org/springframework/boot/buildpack/platform/build/*Cache*' -d "$W/bp"
unzip -q -o "$M/spring-boot-maven-plugin/4.2.0-M2/spring-boot-maven-plugin-4.2.0-M2.jar" 'org/springframework/boot/maven/*' -d "$W/bp"
echo "--- javap Cache (4.2.0-M2)"
javap -cp "$W/bp" org.springframework.boot.buildpack.platform.build.Cache 2>/dev/null | sed 's/org.springframework.boot.buildpack.platform.build.//g; s/org.springframework.boot.buildpack.platform.docker.type.//g'
for c in 'org.springframework.boot.maven.CacheInfo' 'org.springframework.boot.maven.LocalCacheInfo'; do
echo "--- javap $c (4.2.0-M2)"
javap -cp "$W/bp" "$c" 2>/dev/null | sed 's/org.springframework.boot.maven.//g; s/org.springframework.boot.buildpack.platform.build.//g'
done
echo "--- javap -p Image, fields mentioning cache/workspace (4.2.0-M2)"
javap -p -cp "$W/bp" org.springframework.boot.maven.Image 2>/dev/null | grep -E '(Cache|buildWorkspace)[a-zA-Z]* [a-zA-Z]+;$' | sed 's/org.springframework.boot.maven.//g'
} > output/04-buildpack-cache-classes.txt
{
echo "# 05-resttemplate-deprecations (javac -Xlint:deprecation on src/broken/UsesRestTemplate.java)"
for pom in pom-41.xml pom.xml; do
mvn -B -q -f $pom dependency:build-classpath -Dmdep.outputFile="$W/cp.txt" >/dev/null 2>&1
label=$([ $pom = pom.xml ] && echo 4.2.0-M2 || echo 4.1.1)
echo "--- Spring Boot $label"
mkdir -p "$W/out-$label"
javac -Xlint:deprecation -d "$W/out-$label" -cp "$(cat "$W/cp.txt")" src/broken/UsesRestTemplate.java 2>&1 | grep -v JAVA_TOOL | sed 's#^src/broken/##' || true
done
} > output/05-resttemplate-deprecations.txt
{
echo "# 06-restclient-replacement (javac -Xlint:deprecation on the RestClient version, Spring Boot 4.2.0-M2)"
mvn -B -q dependency:build-classpath -Dmdep.outputFile="$W/cp.txt" >/dev/null 2>&1
mkdir -p "$W/out-rc"
javac -Xlint:deprecation -Xlint:removal -d "$W/out-rc" -cp "$(cat "$W/cp.txt")" src/main/java/com/ankurm/boot42/RestClientReplacement.java 2>&1 | grep -v JAVA_TOOL | sed 's#^src/main/java/com/ankurm/boot42/##' || true
echo "javac finished; lines above this one are warnings (none means a clean compile)"
} > output/06-restclient-replacement.txt
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Regenerates every file under output/. Needs JDK 25 and Maven 3.9 with network access to Maven Central.
# ./scripts/run-all.sh
# 01-02 come from the test suite (run on 4.2.0-M2, then on 4.1.1 via pom-41.xml, which writes the *-on-4.1.1 files).
# 03-06 come from jars, POMs and javac. Port numbers are random, so the transcripts print "<random>".
set -euo pipefail
cd "$(dirname "$0")/.."
mvn -B test
mvn -B -f pom-41.xml test
./scripts/capture-facts.sh
ls -1 output
@@ -0,0 +1,19 @@
import org.springframework.boot.restclient.RestTemplateBuilder;
import org.springframework.boot.resttestclient.TestRestTemplate;
import org.springframework.web.client.RestTemplate;
/** Not part of the build: compiled by scripts/capture-facts.sh so the compiler's own words are committed. */
class UsesRestTemplate {
RestTemplate viaBuilder(RestTemplateBuilder builder) {
return builder.build();
}
TestRestTemplate viaTest(TestRestTemplate t) {
return t;
}
RestTemplate plain() {
return new RestTemplate();
}
}
@@ -0,0 +1,9 @@
package com.ankurm.boot42;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/** Minimal application. Every experiment in this module lives in src/test. */
@SpringBootApplication
@org.springframework.boot.context.properties.EnableConfigurationProperties(OrderProperties.class)
public class Boot42PreviewApplication {
}
@@ -0,0 +1,10 @@
package com.ankurm.boot42;
import java.util.Map;
import java.util.Set;
import org.springframework.boot.context.properties.ConfigurationProperties;
/** A Set and a Map bound from a file where the keys are deliberately not alphabetical. */
@ConfigurationProperties("app")
public record OrderProperties(Set<String> regions, Map<String, Integer> limits) {
}
@@ -0,0 +1,15 @@
package com.ankurm.boot42;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;
/** What the deprecated RestTemplateBuilder code turns into: an injected RestClient.Builder. */
@Configuration(proxyBeanMethods = false)
class RestClientReplacement {
@Bean
RestClient inventoryClient(RestClient.Builder builder) {
return builder.baseUrl("http://inventory.internal").build();
}
}
@@ -0,0 +1,23 @@
package com.ankurm.boot42;
import jakarta.servlet.http.HttpServletRequest;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/** Echoes what the servlet container believes about the request, after any forwarded-header handling. */
@RestController
class WhoAmIController {
@GetMapping("/whoami")
Map<String, Object> whoAmI(HttpServletRequest request) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("scheme", request.getScheme());
m.put("secure", request.isSecure());
m.put("serverName", request.getServerName());
m.put("serverPort", request.getServerPort());
m.put("remoteAddr", request.getRemoteAddr());
return m;
}
}
@@ -0,0 +1,7 @@
app.regions=zulu,alpha,mike,bravo,yankee,charlie
app.limits.zulu=1
app.limits.alpha=2
app.limits.mike=3
app.limits.bravo=4
app.limits.yankee=5
app.limits.charlie=6
@@ -0,0 +1,26 @@
package com.ankurm.boot42;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.ArrayList;
import org.junit.jupiter.api.Test;
import org.springframework.context.ConfigurableApplicationContext;
/** 4.2.0-M1: Sets and Maps bind consistently and keep the order of the configuration file. */
class BindingOrderTest {
@Test
void recordsIterationOrder() {
Transcript t = new Transcript("02-set-and-map-binding-order");
try (ConfigurableApplicationContext ctx = BootRun.start("spring.config.name=order")) {
OrderProperties p = ctx.getBean(OrderProperties.class);
t.line("file order : zulu, alpha, mike, bravo, yankee, charlie");
t.line("Set class : " + p.regions().getClass().getName());
t.line("Set order : " + new ArrayList<>(p.regions()));
t.line("Map class : " + p.limits().getClass().getName());
t.line("Map key order : " + new ArrayList<>(p.limits().keySet()));
t.write();
assertThat(p.regions()).hasSize(6);
}
}
}
@@ -0,0 +1,32 @@
package com.ankurm.boot42;
import java.util.Map;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.core.env.Environment;
import org.springframework.web.client.RestClient;
/** Starts the real application with the given properties on a random port. */
final class BootRun {
private BootRun() {
}
static ConfigurableApplicationContext start(String... props) {
return new SpringApplicationBuilder(Boot42PreviewApplication.class)
.web(WebApplicationType.SERVLET)
.properties(props)
.properties("server.port=0", "spring.main.banner-mode=off", "logging.level.root=WARN")
.run();
}
static RestClient client(ConfigurableApplicationContext ctx) {
Environment env = ctx.getEnvironment();
return RestClient.create("http://localhost:" + env.getProperty("local.server.port"));
}
static Map<?, ?> whoAmI(ConfigurableApplicationContext ctx, Map<String, String> headers) {
return client(ctx).get().uri("/whoami").headers(h -> headers.forEach(h::add)).retrieve().body(Map.class);
}
}
@@ -0,0 +1,67 @@
package com.ankurm.boot42;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.LinkedHashMap;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.context.ConfigurableApplicationContext;
/**
* 4.2.0-M1: "When server.forward-headers-strategy=FRAMEWORK is selected, X-Forwarded-* headers are used by
* default." Sends both header styles under each strategy and records what the container believes.
*/
class ForwardedHeadersTest {
private static final Map<String, String> X_FORWARDED = Map.of("X-Forwarded-Proto", "https",
"X-Forwarded-Host", "shop.example.com", "X-Forwarded-Port", "443");
private static final Map<String, String> RFC_7239 = Map.of("Forwarded", "proto=https;host=shop.example.com");
private static final Map<String, Map<String, String>> HEADERS = new LinkedHashMap<>();
static {
HEADERS.put("x-forwarded", X_FORWARDED);
HEADERS.put("rfc7239", RFC_7239);
}
@Test
void recordsBehaviourPerStrategy() {
Transcript t = new Transcript("01-forwarded-headers");
Map<String, Map<?, ?>> results = new LinkedHashMap<>();
for (String strategy : new String[] { "none", "native", "framework" }) {
for (var e : HEADERS.entrySet()) {
try (ConfigurableApplicationContext ctx = BootRun.start("server.forward-headers-strategy=" + strategy)) {
Map<?, ?> seen = BootRun.whoAmI(ctx, e.getValue());
String key = strategy + "/" + e.getKey();
results.put(key, seen);
t.line(String.format("%-22s scheme=%s secure=%s host=%s port=%s", key, seen.get("scheme"),
seen.get("secure"), seen.get("serverName"), seen.get("serverPort").toString().equals("443") ? "443" : "<random>"));
}
}
}
// the new 4.2 knob: which header family the FRAMEWORK strategy reads
for (var e : HEADERS.entrySet()) {
try (ConfigurableApplicationContext ctx = BootRun.start("server.forward-headers-strategy=framework",
"spring.mvc.forwarded-headers.header-format=standard")) {
Map<?, ?> seen = BootRun.whoAmI(ctx, e.getValue());
t.line(String.format("%-22s scheme=%s secure=%s host=%s port=%s", "framework+standard/" + e.getKey(),
seen.get("scheme"), seen.get("secure"), seen.get("serverName"), seen.get("serverPort").toString().equals("443") ? "443" : "<random>"));
}
}
// my first guess at the value was "forwarded"; record what the binder says about it
try (ConfigurableApplicationContext ctx = BootRun.start("server.forward-headers-strategy=framework",
"spring.mvc.forwarded-headers.header-format=forwarded")) {
t.line("header-format=forwarded -> started (property ignored)");
}
catch (Exception ex) {
Throwable root = ex;
while (root.getCause() != null) {
root = root.getCause();
}
t.line("header-format=forwarded -> " + root.getClass().getSimpleName() + ": " + root.getMessage());
}
t.write();
// a strategy of "none" must never trust either header
assertThat(results.get("none/x-forwarded").get("scheme")).isEqualTo("http");
assertThat(results.get("none/rfc7239").get("scheme")).isEqualTo("http");
}
}
@@ -0,0 +1,36 @@
package com.ankurm.boot42;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
/** Collects lines and writes output/NN-name.txt. The test asserts the same values it writes. */
final class Transcript {
private final List<String> lines = new ArrayList<>();
private final Path file;
Transcript(String name) {
String v = System.getProperty("boot.label", "4.2.0-M2");
String suffix = v.equals("4.2.0-M2") ? "" : "-on-" + v;
this.file = Path.of("output", name + suffix + ".txt");
line("# " + name + " (Spring Boot " + v + ", JDK " + Runtime.version().feature() + ")");
}
Transcript line(String s) {
lines.add(s);
return this;
}
void write() {
try {
Files.createDirectories(file.getParent());
Files.write(file, lines);
}
catch (IOException ex) {
throw new IllegalStateException(ex);
}
}
}
+76
View File
@@ -0,0 +1,76 @@
# data-jpa-testcontainers
Companion module for [`@DataJpaTest` with Testcontainers `@ServiceConnection` on Spring Boot 4.1](https://ankurm.com/) on ankurm.com.
## What this is
Six JUnit 5 test classes against one JPA repository (`Product`), each isolating one fact about
what `@DataJpaTest`'s default embedded-database substitution actually does, what
`@ServiceConnection` wires in its place, and what the defaults do run that you might not expect
(Flyway migrations, as it turns out, are one of them). No `docs/NN-topic.md` chapters —
everything beyond what fits in the post itself is in the Javadoc on these six test classes and in
the captured transcripts below.
**Correction, made before this module was published:** an earlier draft of two of these classes
asserted that `@DataJpaTest` never runs Flyway or Liquibase migrations, on the strength of reading
its four documented meta-annotations' `.imports` manifests and finding no mention of either tool.
That reading was accurate and incomplete — see `FlywayRunsByDefaultTest`'s Javadoc and
`docs/output/06-autoconfigurejdbc-meta-annotation-javap.txt` for the fifth, undocumented-for-this-slice
meta-annotation that actually wires them in. The two test classes below that carried the wrong
premise (`FlywayDoesNotRunUnderPlainDataJpaTestTest`, `FlywayRunsWhenExplicitlyImportedTest`) were
replaced, not patched, with `FlywayRunsByDefaultTest` and `ExplicitFlywayImportIsRedundantTest`.
**This module was built and tested in a sandbox with no Docker daemon available.** Five of the six
test classes run and pass (or cleanly skip) without one. The sixth demonstrates, on purpose, the
real failure you get from the one case that genuinely needs a container. See the versions table and
the test table below for exactly which is which.
## Versions
| Dependency | Version | Verified against |
|---|---|---|
| Spring Boot | 4.1.1 | `spring-boot-dependencies-4.1.1.pom` |
| Testcontainers | 2.0.5 | same BOM; `testcontainers-bom-2.0.5.pom` |
| Flyway | 12.4.0 | same BOM |
| Hibernate ORM | 7.4.5.Final | runtime log, this module's own test run |
| H2 | 2.4.240 | runtime log, this module's own test run |
| JDK | 25 (Temurin) | `java -version`, this module's own build |
## Quickstart
```bash
mvn test # 9 tests run; 1 skips cleanly
mvn test -Dtest=ServiceConnectionWithoutGuardFailsToStartTest # the one deliberate failure
```
## Test classes
| Class | What it proves | Needs Docker | Outcome here |
|---|---|---|---|
| `DefaultReplacementTest` | Plain `@DataJpaTest` substitutes an embedded H2 DataSource, and each test method is rolled back independently of execution order | No | Pass |
| `FlywayRunsByDefaultTest` | A plain `@DataJpaTest`, with flyway-core and `spring-boot-starter-flyway` on the (test) classpath, runs the real Flyway migration by default — no `@ImportAutoConfiguration` needed, because `@AutoConfigureJdbc` is itself meta-annotated with `@AutoConfigureDataSourceInitialization`, which merges contributions from every jar on the classpath that ships one | No | Pass |
| `ExplicitFlywayImportIsRedundantTest` | Explicitly re-adding `@ImportAutoConfiguration(FlywayAutoConfiguration.class)` — the old advice for the (incorrect) premise above — changes nothing: still exactly one `flyway` bean, still exactly one applied migration | No | Pass |
| `NoSubstitutionStillSilentlyUsesH2Test` | `@AutoConfigureTestDatabase(replace = Replace.NONE)` alone is not a guarantee of a real connection — with nothing else configured, ordinary `DataSourceAutoConfiguration` still silently hands you embedded H2 | No | Pass |
| `ServiceConnectionSkipsCleanlyTest` | The intended shape: `@ServiceConnection` + `@Container` + `replace = Replace.NONE`, guarded by Testcontainers 2.0's new `@EnabledIfDockerAvailable` | Yes (guarded) | Skipped |
| `ServiceConnectionWithoutGuardFailsToStartTest` | The same class, minus the guard | Yes (unguarded) | **Fails — on purpose.** Excluded from `mvn test` by name in `pom.xml`; run it explicitly |
## Captured output
| File | Contents |
|---|---|
| `docs/output/00-full-test-run.txt` | `mvn clean test` — 9 tests, 1 skip, build green |
| `docs/output/01-postgresqlcontainer-relocation-javap.txt` | `javap` proof that `org.testcontainers.postgresql.PostgreSQLContainer` (2.0.5's new home) and the deprecated `org.testcontainers.containers.PostgreSQLContainer` shim live in the *same* jar, and that the old class alone keeps the self-referential `<SELF extends ...>` generic |
| `docs/output/02-serviceconnection-unaffected-javap.txt` | `javap` proof that `JdbcContainerConnectionDetailsFactory` is typed against `org.testcontainers.containers.JdbcDatabaseContainer<?>` — unmoved by the 2.0 package split, which is why `@ServiceConnection` needed no changes |
| `docs/output/04-service-connection-without-guard-fails.txt` | The real `ContainerFetchException` / `IllegalStateException` from trying to start a Postgres container with no Docker daemon present, and no guard against that |
| `docs/output/05-service-connection-skips-cleanly.txt` | The same absence of Docker, this time behind `@EnabledIfDockerAvailable` — one line, `Skipped: 1`, no failure |
| `docs/output/06-autoconfigurejdbc-meta-annotation-javap.txt` | `javap` proof that `AutoConfigureJdbc.class` is meta-annotated with `@AutoConfigureDataSourceInitialization`, plus the matching `META-INF/spring/...AutoConfigureDataSourceInitialization.imports` file contents from both `spring-boot-jdbc-test` and `spring-boot-flyway` — the actual mechanism behind `FlywayRunsByDefaultTest` |
## What's sourced from code, not run here
Two things this article discusses are backed by reading Spring Boot's and Testcontainers' own
class files and import manifests rather than by a run in this sandbox, and are called out as such
in the post itself: what a *successful* real-Postgres run under `@ServiceConnection` would actually
return once a Docker daemon is present (the wiring is verified; the live round trip is not), and
Testcontainers' reusable-container feature (`.withReuse(true)` plus `~/.testcontainers.properties`),
which has nothing to do with Docker availability but was out of scope for a module built in one
sitting.
@@ -0,0 +1,185 @@
Picked up JAVA_TOOL_OPTIONS: -Djavax.net.ssl.trustStore=/etc/ssl/certs/java/cacerts -Djavax.net.ssl.trustStoreType=JKS -Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort=34689 -Dhttp.nonProxyHosts=localhost|127.0.0.1|::1|127.*|0.*|::|169.254.*|api.anthropic.com|api-staging.anthropic.com|api-pr-preview.anthropic.com|mcp-proxy.anthropic.com|mcp-proxy-staging.anthropic.com|registry.npmjs.org|jsr.io|npm.jsr.io|pypi.org|files.pythonhosted.org|index.crates.io|proxy.golang.org|host.docker.internal|10.*|172.16.*|172.17.*|172.18.*|172.19.*|172.20.*|172.21.*|172.22.*|172.23.*|172.24.*|172.25.*|172.26.*|172.27.*|172.28.*|172.29.*|172.30.*|172.31.*|192.168.*|100.64.0.0/10|*.svc.cluster.local|*.svc.cluster.local -Djdk.http.auth.tunneling.disabledSchemes= -Djdk.http.auth.proxying.disabledSchemes=
WARNING: A terminally deprecated method in sun.misc.Unsafe has been called
WARNING: sun.misc.Unsafe::staticFieldBase has been called by com.google.inject.internal.aop.HiddenClassDefiner (file:/opt/maven/lib/guice-5.1.0-classes.jar)
WARNING: Please consider reporting this to the maintainers of class com.google.inject.internal.aop.HiddenClassDefiner
WARNING: sun.misc.Unsafe::staticFieldBase will be removed in a future release
[INFO] Scanning for projects...
[INFO]
[INFO] -----------------< com.ankurm:data-jpa-testcontainers >-----------------
[INFO] Building data-jpa-testcontainers 1.0.0
[INFO] from pom.xml
[INFO] --------------------------------[ jar ]---------------------------------
[INFO]
[INFO] --- clean:3.5.0:clean (default-clean) @ data-jpa-testcontainers ---
[INFO] Deleting /home/claude/spring-boot-demo/data-jpa-testcontainers/target
[INFO]
[INFO] --- resources:3.5.0:resources (default-resources) @ data-jpa-testcontainers ---
[INFO] Copying 0 resource from src/main/resources to target/classes
[INFO] Copying 1 resource from src/main/resources to target/classes
[INFO]
[INFO] --- compiler:3.15.0:compile (default-compile) @ data-jpa-testcontainers ---
[INFO] Recompiling the module because of changed source code.
[INFO] Compiling 3 source files with javac [debug parameters release 25] to target/classes
[INFO]
[INFO] --- resources:3.5.0:testResources (default-testResources) @ data-jpa-testcontainers ---
[INFO] skip non existing resourceDirectory /home/claude/spring-boot-demo/data-jpa-testcontainers/src/test/resources
[INFO]
[INFO] --- compiler:3.15.0:testCompile (default-testCompile) @ data-jpa-testcontainers ---
[INFO] Recompiling the module because of changed dependency.
[INFO] Compiling 6 source files with javac [debug parameters release 25] to target/test-classes
[INFO]
[INFO] --- surefire:3.5.6:test (default-test) @ data-jpa-testcontainers ---
[INFO] Using auto detected provider org.apache.maven.surefire.junitplatform.JUnitPlatformProvider
[INFO]
[INFO] -------------------------------------------------------
[INFO] T E S T S
[INFO] -------------------------------------------------------
Picked up JAVA_TOOL_OPTIONS: -Djavax.net.ssl.trustStore=/etc/ssl/certs/java/cacerts -Djavax.net.ssl.trustStoreType=JKS -Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort=34689 -Dhttp.nonProxyHosts=localhost|127.0.0.1|::1|127.*|0.*|::|169.254.*|api.anthropic.com|api-staging.anthropic.com|api-pr-preview.anthropic.com|mcp-proxy.anthropic.com|mcp-proxy-staging.anthropic.com|registry.npmjs.org|jsr.io|npm.jsr.io|pypi.org|files.pythonhosted.org|index.crates.io|proxy.golang.org|host.docker.internal|10.*|172.16.*|172.17.*|172.18.*|172.19.*|172.20.*|172.21.*|172.22.*|172.23.*|172.24.*|172.25.*|172.26.*|172.27.*|172.28.*|172.29.*|172.30.*|172.31.*|192.168.*|100.64.0.0/10|*.svc.cluster.local|*.svc.cluster.local -Djdk.http.auth.tunneling.disabledSchemes= -Djdk.http.auth.proxying.disabledSchemes=
[INFO] Running com.ankurm.datajpa.DefaultReplacementTest
17:55:42.824 [main] INFO org.springframework.test.context.support.AnnotationConfigContextLoaderUtils -- Could not detect default configuration classes for test class [com.ankurm.datajpa.DefaultReplacementTest]: DefaultReplacementTest does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
17:55:43.042 [main] INFO org.springframework.boot.test.context.SpringBootTestContextBootstrapper -- Found @SpringBootConfiguration com.ankurm.datajpa.DataJpaTestcontainersApplication for test class com.ankurm.datajpa.DefaultReplacementTest
17:55:43.046 [main] INFO org.springframework.test.context.support.AnnotationConfigContextLoaderUtils -- Could not detect default configuration classes for test class [com.ankurm.datajpa.DefaultReplacementTest]: DefaultReplacementTest does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
17:55:43.073 [main] INFO org.springframework.boot.test.context.SpringBootTestContextBootstrapper -- Found @SpringBootConfiguration com.ankurm.datajpa.DataJpaTestcontainersApplication for test class com.ankurm.datajpa.DefaultReplacementTest
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v4.1.1)
2026-10-08T17:55:43.593+05:30 INFO 10940 --- [ main] c.ankurm.datajpa.DefaultReplacementTest : Starting DefaultReplacementTest using Java 25.0.4.1 with PID 10940 (started by root in /home/claude/spring-boot-demo/data-jpa-testcontainers)
2026-10-08T17:55:43.595+05:30 INFO 10940 --- [ main] c.ankurm.datajpa.DefaultReplacementTest : No active profile set, falling back to 1 default profile: "default"
2026-10-08T17:55:44.217+05:30 INFO 10940 --- [ main] .s.d.r.c.RepositoryConfigurationDelegate : Bootstrapping Spring Data JPA repositories in DEFAULT mode.
2026-10-08T17:55:44.349+05:30 INFO 10940 --- [ main] .s.d.r.c.RepositoryConfigurationDelegate : Finished Spring Data repository scanning in 118 ms. Found 1 JPA repository interface.
2026-10-08T17:55:44.495+05:30 INFO 10940 --- [ main] beddedDataSourceBeanFactoryPostProcessor : Replacing 'dataSource' DataSource bean with embedded version
2026-10-08T17:55:44.784+05:30 INFO 10940 --- [ main] o.s.j.d.e.EmbeddedDatabaseFactory : Starting embedded database: url='jdbc:h2:mem:4440be02-551f-486e-a4b1-2182bb84c7a5;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=false', username='sa'
2026-10-08T17:55:45.448+05:30 INFO 10940 --- [ main] org.flywaydb.core.FlywayExecutor : Database: jdbc:h2:mem:4440be02-551f-486e-a4b1-2182bb84c7a5 (H2 2.4)
2026-10-08T17:55:45.514+05:30 WARN 10940 --- [ main] o.f.c.internal.database.base.Database : Using H2 2.4.240 which is newer than the version Flyway has been verified with. The latest verified version of H2 is 2.3.232.
2026-10-08T17:55:45.551+05:30 INFO 10940 --- [ main] o.f.c.i.s.JdbcTableSchemaHistory : Schema history table "PUBLIC"."flyway_schema_history" does not exist yet
2026-10-08T17:55:45.555+05:30 INFO 10940 --- [ main] o.f.core.internal.command.DbValidate : Successfully validated 1 migration (execution time 00:00.014s)
2026-10-08T17:55:45.582+05:30 INFO 10940 --- [ main] o.f.c.i.s.JdbcTableSchemaHistory : Creating Schema History table "PUBLIC"."flyway_schema_history" ...
2026-10-08T17:55:45.640+05:30 INFO 10940 --- [ main] o.f.core.internal.command.DbMigrate : Current version of schema "PUBLIC": << Empty Schema >>
2026-10-08T17:55:45.652+05:30 INFO 10940 --- [ main] o.f.core.internal.command.DbMigrate : Migrating schema "PUBLIC" to version "1 - create product"
2026-10-08T17:55:45.750+05:30 INFO 10940 --- [ main] o.f.core.internal.command.DbMigrate : Successfully applied 1 migration to schema "PUBLIC", now at version v1 (execution time 00:00.016s)
2026-10-08T17:55:46.001+05:30 INFO 10940 --- [ main] org.hibernate.orm.jpa : HHH008540: Processing PersistenceUnitInfo [name: default]
2026-10-08T17:55:46.079+05:30 INFO 10940 --- [ main] org.hibernate.orm.core : HHH000001: Hibernate ORM core version 7.4.5.Final
2026-10-08T17:55:46.707+05:30 INFO 10940 --- [ main] o.s.o.j.p.SpringPersistenceUnitInfo : No LoadTimeWeaver setup: ignoring JPA class transformer
2026-10-08T17:55:46.832+05:30 INFO 10940 --- [ main] org.hibernate.orm.connections.pooling : HHH10001005: Database info:
Database JDBC URL [jdbc:h2:mem:4440be02-551f-486e-a4b1-2182bb84c7a5]
Database driver: H2 JDBC Driver
Database dialect: H2Dialect
Database version: 2.4.240
Default catalog/schema: 4440BE02-551F-486E-A4B1-2182BB84C7A5/PUBLIC
Autocommit mode: undefined/unknown
Isolation level: READ_COMMITTED [default READ_COMMITTED]
JDBC fetch size: 100
Pool: DataSourceConnectionProvider
Minimum pool size: undefined/unknown
Maximum pool size: undefined/unknown
2026-10-08T17:55:47.973+05:30 INFO 10940 --- [ main] org.hibernate.orm.core : HHH000489: No JTA platform available (set 'hibernate.transaction.jta.platform' to enable JTA platform integration)
2026-10-08T17:55:47.989+05:30 INFO 10940 --- [ main] j.LocalContainerEntityManagerFactoryBean : Initialized JPA EntityManagerFactory for persistence unit 'default'
2026-10-08T17:55:48.156+05:30 INFO 10940 --- [ main] o.s.d.j.r.query.QueryEnhancerFactories : Hibernate is in classpath; If applicable, HQL parser will be used.
2026-10-08T17:55:48.476+05:30 INFO 10940 --- [ main] c.ankurm.datajpa.DefaultReplacementTest : Started DefaultReplacementTest in 5.325 seconds (process running for 7.158)
Mockito is currently self-attaching to enable the inline-mock-maker. This will no longer work in future releases of the JDK. Please add Mockito as an agent to your build as described in Mockito's documentation: https://javadoc.io/doc/org.mockito/mockito-core/latest/org.mockito/org/mockito/Mockito.html#0.3
OpenJDK 64-Bit Server VM warning: Sharing is only supported for boot loader classes because bootstrap classpath has been appended
WARNING: A Java agent has been loaded dynamically (/root/.m2/repository/net/bytebuddy/byte-buddy-agent/1.18.11/byte-buddy-agent-1.18.11.jar)
WARNING: If a serviceability tool is in use, please run with -XX:+EnableDynamicAgentLoading to hide this warning
WARNING: If a serviceability tool is not in use, please run with -Djdk.instrument.traceUsage for more information
WARNING: Dynamic loading of agents will be disallowed by default in a future release
Hibernate: select count(*) from product p1_0
Hibernate: select p1_0.id,p1_0.name,p1_0.sku from product p1_0 where p1_0.sku=?
JDBC reports database product name: H2
Hibernate: insert into product (name,sku,id) values (?,?,default)
Hibernate: select p1_0.id,p1_0.name,p1_0.sku from product p1_0 where p1_0.sku=?
Hibernate: select count(*) from product p1_0
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 7.500 s -- in com.ankurm.datajpa.DefaultReplacementTest
[INFO] Running com.ankurm.datajpa.NoSubstitutionStillSilentlyUsesH2Test
2026-10-08T17:55:50.002+05:30 INFO 10940 --- [ main] t.c.s.AnnotationConfigContextLoaderUtils : Could not detect default configuration classes for test class [com.ankurm.datajpa.NoSubstitutionStillSilentlyUsesH2Test]: NoSubstitutionStillSilentlyUsesH2Test does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
2026-10-08T17:55:50.040+05:30 INFO 10940 --- [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.datajpa.DataJpaTestcontainersApplication for test class com.ankurm.datajpa.NoSubstitutionStillSilentlyUsesH2Test
2026-10-08T17:55:50.041+05:30 INFO 10940 --- [ main] t.c.s.AnnotationConfigContextLoaderUtils : Could not detect default configuration classes for test class [com.ankurm.datajpa.NoSubstitutionStillSilentlyUsesH2Test]: NoSubstitutionStillSilentlyUsesH2Test does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
2026-10-08T17:55:50.069+05:30 INFO 10940 --- [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.datajpa.DataJpaTestcontainersApplication for test class com.ankurm.datajpa.NoSubstitutionStillSilentlyUsesH2Test
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v4.1.1)
2026-10-08T17:55:50.166+05:30 INFO 10940 --- [ main] .d.NoSubstitutionStillSilentlyUsesH2Test : Starting NoSubstitutionStillSilentlyUsesH2Test using Java 25.0.4.1 with PID 10940 (started by root in /home/claude/spring-boot-demo/data-jpa-testcontainers)
2026-10-08T17:55:50.167+05:30 INFO 10940 --- [ main] .d.NoSubstitutionStillSilentlyUsesH2Test : No active profile set, falling back to 1 default profile: "default"
2026-10-08T17:55:50.366+05:30 INFO 10940 --- [ main] .s.d.r.c.RepositoryConfigurationDelegate : Bootstrapping Spring Data JPA repositories in DEFAULT mode.
2026-10-08T17:55:50.381+05:30 INFO 10940 --- [ main] .s.d.r.c.RepositoryConfigurationDelegate : Finished Spring Data repository scanning in 12 ms. Found 1 JPA repository interface.
2026-10-08T17:55:50.538+05:30 INFO 10940 --- [ main] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Starting...
2026-10-08T17:55:50.563+05:30 INFO 10940 --- [ main] com.zaxxer.hikari.pool.HikariPool : HikariPool-1 - Added connection conn9: url=jdbc:h2:mem:59a60d3a-9329-4255-85ca-9683b850ef31 user=SA
2026-10-08T17:55:50.566+05:30 INFO 10940 --- [ main] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Start completed.
2026-10-08T17:55:50.573+05:30 INFO 10940 --- [ main] org.flywaydb.core.FlywayExecutor : Database: jdbc:h2:mem:59a60d3a-9329-4255-85ca-9683b850ef31 (H2 2.4)
2026-10-08T17:55:50.597+05:30 WARN 10940 --- [ main] o.f.c.internal.database.base.Database : Using H2 2.4.240 which is newer than the version Flyway has been verified with. The latest verified version of H2 is 2.3.232.
2026-10-08T17:55:50.607+05:30 INFO 10940 --- [ main] o.f.c.i.s.JdbcTableSchemaHistory : Schema history table "PUBLIC"."flyway_schema_history" does not exist yet
2026-10-08T17:55:50.608+05:30 INFO 10940 --- [ main] o.f.core.internal.command.DbValidate : Successfully validated 1 migration (execution time 00:00.005s)
2026-10-08T17:55:50.610+05:30 INFO 10940 --- [ main] o.f.c.i.s.JdbcTableSchemaHistory : Creating Schema History table "PUBLIC"."flyway_schema_history" ...
2026-10-08T17:55:50.723+05:30 INFO 10940 --- [ main] o.f.core.internal.command.DbMigrate : Current version of schema "PUBLIC": << Empty Schema >>
2026-10-08T17:55:50.735+05:30 INFO 10940 --- [ main] o.f.core.internal.command.DbMigrate : Migrating schema "PUBLIC" to version "1 - create product"
2026-10-08T17:55:50.745+05:30 INFO 10940 --- [ main] o.f.core.internal.command.DbMigrate : Successfully applied 1 migration to schema "PUBLIC", now at version v1 (execution time 00:00.006s)
2026-10-08T17:55:50.829+05:30 INFO 10940 --- [ main] org.hibernate.orm.jpa : HHH008540: Processing PersistenceUnitInfo [name: default]
2026-10-08T17:55:50.897+05:30 INFO 10940 --- [ main] o.s.o.j.p.SpringPersistenceUnitInfo : No LoadTimeWeaver setup: ignoring JPA class transformer
2026-10-08T17:55:50.902+05:30 INFO 10940 --- [ main] org.hibernate.orm.connections.pooling : HHH10001005: Database info:
Database JDBC URL [jdbc:h2:mem:59a60d3a-9329-4255-85ca-9683b850ef31]
Database driver: H2 JDBC Driver
Database dialect: H2Dialect
Database version: 2.4.240
Default catalog/schema: 59A60D3A-9329-4255-85CA-9683B850EF31/PUBLIC
Autocommit mode: undefined/unknown
Isolation level: READ_COMMITTED [default READ_COMMITTED]
JDBC fetch size: 100
Pool: DataSourceConnectionProvider
Minimum pool size: undefined/unknown
Maximum pool size: undefined/unknown
2026-10-08T17:55:50.956+05:30 INFO 10940 --- [ main] org.hibernate.orm.core : HHH000489: No JTA platform available (set 'hibernate.transaction.jta.platform' to enable JTA platform integration)
2026-10-08T17:55:50.958+05:30 INFO 10940 --- [ main] j.LocalContainerEntityManagerFactoryBean : Initialized JPA EntityManagerFactory for persistence unit 'default'
2026-10-08T17:55:51.035+05:30 INFO 10940 --- [ main] .d.NoSubstitutionStillSilentlyUsesH2Test : Started NoSubstitutionStillSilentlyUsesH2Test in 0.949 seconds (process running for 9.717)
JDBC reports database product name: H2 (even with replace = Replace.NONE and no @ServiceConnection anywhere)
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.083 s -- in com.ankurm.datajpa.NoSubstitutionStillSilentlyUsesH2Test
[INFO] Running com.ankurm.datajpa.ExplicitFlywayImportIsRedundantTest
2026-10-08T17:55:51.081+05:30 INFO 10940 --- [ main] t.c.s.AnnotationConfigContextLoaderUtils : Could not detect default configuration classes for test class [com.ankurm.datajpa.ExplicitFlywayImportIsRedundantTest]: ExplicitFlywayImportIsRedundantTest does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
2026-10-08T17:55:51.135+05:30 INFO 10940 --- [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.datajpa.DataJpaTestcontainersApplication for test class com.ankurm.datajpa.ExplicitFlywayImportIsRedundantTest
2026-10-08T17:55:51.139+05:30 INFO 10940 --- [ main] t.c.s.AnnotationConfigContextLoaderUtils : Could not detect default configuration classes for test class [com.ankurm.datajpa.ExplicitFlywayImportIsRedundantTest]: ExplicitFlywayImportIsRedundantTest does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
2026-10-08T17:55:51.195+05:30 INFO 10940 --- [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.datajpa.DataJpaTestcontainersApplication for test class com.ankurm.datajpa.ExplicitFlywayImportIsRedundantTest
flyway_schema_history real migration rows (rank >= 0): 1
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.226 s -- in com.ankurm.datajpa.ExplicitFlywayImportIsRedundantTest
2026-10-08T17:55:51.324+05:30 INFO 10940 --- [ main] org.testcontainers.DockerClientFactory : Testcontainers version: 2.0.5
WARNING: A restricted method in java.lang.System has been called
WARNING: java.lang.System::load has been called by com.sun.jna.Native in an unnamed module (file:/root/.m2/repository/net/java/dev/jna/jna/5.18.1/jna-5.18.1.jar)
WARNING: Use --enable-native-access=ALL-UNNAMED to avoid a warning for callers in this module
WARNING: Restricted methods will be blocked in a future release unless native access is enabled
2026-10-08T17:55:51.689+05:30 INFO 10940 --- [ main] .t.d.DockerMachineClientProviderStrategy : docker-machine executable was not found on PATH ([/opt/jdk-25/bin, /home/claude/.npm-global/bin, /nix/var/nix/profiles/default/bin, /root/.local/bin, /root/.cargo/bin, /usr/local/go/bin, /opt/node22/bin, /opt/maven/bin, /opt/gradle/bin, /opt/rbenv/bin, /root/.bun/bin, /usr/local/sbin, /usr/local/bin, /usr/sbin, /usr/bin, /sbin, /bin])
2026-10-08T17:55:51.691+05:30 ERROR 10940 --- [ main] o.t.d.DockerClientProviderStrategy : Could not find a valid Docker environment. Please check configuration. Attempted configurations were:
UnixSocketClientProviderStrategy: failed with exception InvalidConfigurationException (Could not find unix domain socket). Root cause NoSuchFileException (/var/run/docker.sock)
DockerDesktopClientProviderStrategy: failed with exception NullPointerException (Cannot invoke "java.nio.file.Path.toString()" because the return value of "org.testcontainers.dockerclient.DockerDesktopClientProviderStrategy.getSocketPath()" is null)As no valid configuration was found, execution cannot continue.
See https://java.testcontainers.org/on_failure.html for more details.
[INFO] Running com.ankurm.datajpa.ServiceConnectionSkipsCleanlyTest
[WARNING] Tests run: 1, Failures: 0, Errors: 0, Skipped: 1, Time elapsed: 0.009 s -- in com.ankurm.datajpa.ServiceConnectionSkipsCleanlyTest
[INFO] Running com.ankurm.datajpa.FlywayRunsByDefaultTest
2026-10-08T17:55:51.702+05:30 INFO 10940 --- [ main] t.c.s.AnnotationConfigContextLoaderUtils : Could not detect default configuration classes for test class [com.ankurm.datajpa.FlywayRunsByDefaultTest]: FlywayRunsByDefaultTest does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
2026-10-08T17:55:51.716+05:30 INFO 10940 --- [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.datajpa.DataJpaTestcontainersApplication for test class com.ankurm.datajpa.FlywayRunsByDefaultTest
2026-10-08T17:55:51.717+05:30 INFO 10940 --- [ main] t.c.s.AnnotationConfigContextLoaderUtils : Could not detect default configuration classes for test class [com.ankurm.datajpa.FlywayRunsByDefaultTest]: FlywayRunsByDefaultTest does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
2026-10-08T17:55:51.731+05:30 INFO 10940 --- [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.datajpa.DataJpaTestcontainersApplication for test class com.ankurm.datajpa.FlywayRunsByDefaultTest
flyway_schema_history row: version=1 description=create product success=true
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.079 s -- in com.ankurm.datajpa.FlywayRunsByDefaultTest
[INFO]
[INFO] Results:
[INFO]
[INFO] Tests run: 9, Failures: 0, Errors: 0, Skipped: 1
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 14.464 s
[INFO] Finished at: 2026-10-08T17:55:51+05:30
[INFO] ------------------------------------------------------------------------
@@ -0,0 +1,82 @@
$ javap -p org/testcontainers/postgresql/PostgreSQLContainer.class # testcontainers-postgresql-2.0.5.jar
Compiled from "PostgreSQLContainer.java"
public class org.testcontainers.postgresql.PostgreSQLContainer extends org.testcontainers.containers.JdbcDatabaseContainer<org.testcontainers.postgresql.PostgreSQLContainer> {
public static final java.lang.String NAME;
public static final java.lang.String IMAGE;
public static final java.lang.String DEFAULT_TAG;
private static final org.testcontainers.utility.DockerImageName DEFAULT_IMAGE_NAME;
private static final org.testcontainers.utility.DockerImageName PGVECTOR_IMAGE_NAME;
public static final java.lang.Integer POSTGRESQL_PORT;
static final java.lang.String DEFAULT_USER;
static final java.lang.String DEFAULT_PASSWORD;
private java.lang.String databaseName;
private java.lang.String username;
private java.lang.String password;
private static final java.lang.String FSYNC_OFF_OPTION;
public org.testcontainers.postgresql.PostgreSQLContainer(java.lang.String);
public org.testcontainers.postgresql.PostgreSQLContainer(org.testcontainers.utility.DockerImageName);
protected java.util.Set<java.lang.Integer> getLivenessCheckPorts();
protected void configure();
public java.lang.String getDriverClassName();
public java.lang.String getJdbcUrl();
public java.lang.String getDatabaseName();
public java.lang.String getUsername();
public java.lang.String getPassword();
public java.lang.String getTestQueryString();
public org.testcontainers.postgresql.PostgreSQLContainer withDatabaseName(java.lang.String);
public org.testcontainers.postgresql.PostgreSQLContainer withUsername(java.lang.String);
public org.testcontainers.postgresql.PostgreSQLContainer withPassword(java.lang.String);
protected void waitUntilContainerStarted();
public org.testcontainers.containers.JdbcDatabaseContainer withDatabaseName(java.lang.String);
public org.testcontainers.containers.JdbcDatabaseContainer withPassword(java.lang.String);
public org.testcontainers.containers.JdbcDatabaseContainer withUsername(java.lang.String);
static {};
}
$ javap -p org/testcontainers/containers/PostgreSQLContainer.class # SAME jar, deprecated shim
Compiled from "PostgreSQLContainer.java"
public class org.testcontainers.containers.PostgreSQLContainer<SELF extends org.testcontainers.containers.PostgreSQLContainer<SELF>> extends org.testcontainers.containers.JdbcDatabaseContainer<SELF> {
public static final java.lang.String NAME;
public static final java.lang.String IMAGE;
public static final java.lang.String DEFAULT_TAG;
private static final org.testcontainers.utility.DockerImageName DEFAULT_IMAGE_NAME;
private static final org.testcontainers.utility.DockerImageName PGVECTOR_IMAGE_NAME;
public static final java.lang.Integer POSTGRESQL_PORT;
static final java.lang.String DEFAULT_USER;
static final java.lang.String DEFAULT_PASSWORD;
private java.lang.String databaseName;
private java.lang.String username;
private java.lang.String password;
private static final java.lang.String FSYNC_OFF_OPTION;
public org.testcontainers.containers.PostgreSQLContainer();
public org.testcontainers.containers.PostgreSQLContainer(java.lang.String);
public org.testcontainers.containers.PostgreSQLContainer(org.testcontainers.utility.DockerImageName);
protected java.util.Set<java.lang.Integer> getLivenessCheckPorts();
protected void configure();
public java.lang.String getDriverClassName();
public java.lang.String getJdbcUrl();
public java.lang.String getDatabaseName();
public java.lang.String getUsername();
public java.lang.String getPassword();
public java.lang.String getTestQueryString();
public SELF withDatabaseName(java.lang.String);
public SELF withUsername(java.lang.String);
public SELF withPassword(java.lang.String);
protected void waitUntilContainerStarted();
public org.testcontainers.containers.JdbcDatabaseContainer withDatabaseName(java.lang.String);
public org.testcontainers.containers.JdbcDatabaseContainer withPassword(java.lang.String);
public org.testcontainers.containers.JdbcDatabaseContainer withUsername(java.lang.String);
static {};
}
$ javap -v org/testcontainers/containers/PostgreSQLContainer.class | grep -i deprecated
#7 = Utf8 Ljava/lang/Deprecated;
#232 = Utf8 Deprecated
Deprecated: true
java.lang.Deprecated
Deprecated: true
$ unzip -l /root/.m2/repository/org/testcontainers/testcontainers-postgresql/2.0.5/testcontainers-postgresql-2.0.5.jar | grep PostgreSQLContainer
5533 2026-04-06 22:36 org/testcontainers/postgresql/PostgreSQLContainer.class
1745 2026-04-06 22:36 org/testcontainers/containers/PostgreSQLContainerProvider.class
6150 2026-04-06 22:36 org/testcontainers/containers/PostgreSQLContainer.class
@@ -0,0 +1,15 @@
$ javap -p org/springframework/boot/jdbc/testcontainers/JdbcContainerConnectionDetailsFactory.class # spring-boot-jdbc-4.1.1.jar
Compiled from "JdbcContainerConnectionDetailsFactory.java"
class org.springframework.boot.jdbc.testcontainers.JdbcContainerConnectionDetailsFactory extends org.springframework.boot.testcontainers.service.connection.ContainerConnectionDetailsFactory<org.testcontainers.containers.JdbcDatabaseContainer<?>, org.springframework.boot.jdbc.autoconfigure.JdbcConnectionDetails> {
org.springframework.boot.jdbc.testcontainers.JdbcContainerConnectionDetailsFactory();
protected org.springframework.boot.jdbc.autoconfigure.JdbcConnectionDetails getContainerConnectionDetails(org.springframework.boot.testcontainers.service.connection.ContainerConnectionSource<org.testcontainers.containers.JdbcDatabaseContainer<?>>);
protected org.springframework.boot.autoconfigure.service.connection.ConnectionDetails getContainerConnectionDetails(org.springframework.boot.testcontainers.service.connection.ContainerConnectionSource);
}
$ javap -p org/springframework/boot/testcontainers/service/connection/ServiceConnection.class # spring-boot-testcontainers-4.1.1.jar
Compiled from "ServiceConnection.java"
public interface org.springframework.boot.testcontainers.service.connection.ServiceConnection extends java.lang.annotation.Annotation {
public abstract java.lang.String value();
public abstract java.lang.String name();
public abstract java.lang.Class<? extends org.springframework.boot.autoconfigure.service.connection.ConnectionDetails>[] type();
}
@@ -0,0 +1,151 @@
Picked up JAVA_TOOL_OPTIONS: -Djavax.net.ssl.trustStore=/etc/ssl/certs/java/cacerts -Djavax.net.ssl.trustStoreType=JKS -Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort=34689 -Dhttp.nonProxyHosts=localhost|127.0.0.1|::1|127.*|0.*|::|169.254.*|api.anthropic.com|api-staging.anthropic.com|api-pr-preview.anthropic.com|mcp-proxy.anthropic.com|mcp-proxy-staging.anthropic.com|registry.npmjs.org|jsr.io|npm.jsr.io|pypi.org|files.pythonhosted.org|index.crates.io|proxy.golang.org|host.docker.internal|10.*|172.16.*|172.17.*|172.18.*|172.19.*|172.20.*|172.21.*|172.22.*|172.23.*|172.24.*|172.25.*|172.26.*|172.27.*|172.28.*|172.29.*|172.30.*|172.31.*|192.168.*|100.64.0.0/10|*.svc.cluster.local|*.svc.cluster.local -Djdk.http.auth.tunneling.disabledSchemes= -Djdk.http.auth.proxying.disabledSchemes=
WARNING: A terminally deprecated method in sun.misc.Unsafe has been called
WARNING: sun.misc.Unsafe::staticFieldBase has been called by com.google.inject.internal.aop.HiddenClassDefiner (file:/opt/maven/lib/guice-5.1.0-classes.jar)
WARNING: Please consider reporting this to the maintainers of class com.google.inject.internal.aop.HiddenClassDefiner
WARNING: sun.misc.Unsafe::staticFieldBase will be removed in a future release
[INFO] Scanning for projects...
[INFO]
[INFO] -----------------< com.ankurm:data-jpa-testcontainers >-----------------
[INFO] Building data-jpa-testcontainers 1.0.0
[INFO] from pom.xml
[INFO] --------------------------------[ jar ]---------------------------------
[INFO]
[INFO] --- resources:3.5.0:resources (default-resources) @ data-jpa-testcontainers ---
[INFO] Copying 0 resource from src/main/resources to target/classes
[INFO] Copying 1 resource from src/main/resources to target/classes
[INFO]
[INFO] --- compiler:3.15.0:compile (default-compile) @ data-jpa-testcontainers ---
[INFO] Nothing to compile - all classes are up to date.
[INFO]
[INFO] --- resources:3.5.0:testResources (default-testResources) @ data-jpa-testcontainers ---
[INFO] skip non existing resourceDirectory /home/claude/spring-boot-demo/data-jpa-testcontainers/src/test/resources
[INFO]
[INFO] --- compiler:3.15.0:testCompile (default-testCompile) @ data-jpa-testcontainers ---
[INFO] Nothing to compile - all classes are up to date.
[INFO]
[INFO] --- surefire:3.5.6:test (default-test) @ data-jpa-testcontainers ---
[INFO] Using auto detected provider org.apache.maven.surefire.junitplatform.JUnitPlatformProvider
[INFO]
[INFO] -------------------------------------------------------
[INFO] T E S T S
[INFO] -------------------------------------------------------
Picked up JAVA_TOOL_OPTIONS: -Djavax.net.ssl.trustStore=/etc/ssl/certs/java/cacerts -Djavax.net.ssl.trustStoreType=JKS -Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort=34689 -Dhttp.nonProxyHosts=localhost|127.0.0.1|::1|127.*|0.*|::|169.254.*|api.anthropic.com|api-staging.anthropic.com|api-pr-preview.anthropic.com|mcp-proxy.anthropic.com|mcp-proxy-staging.anthropic.com|registry.npmjs.org|jsr.io|npm.jsr.io|pypi.org|files.pythonhosted.org|index.crates.io|proxy.golang.org|host.docker.internal|10.*|172.16.*|172.17.*|172.18.*|172.19.*|172.20.*|172.21.*|172.22.*|172.23.*|172.24.*|172.25.*|172.26.*|172.27.*|172.28.*|172.29.*|172.30.*|172.31.*|192.168.*|100.64.0.0/10|*.svc.cluster.local|*.svc.cluster.local -Djdk.http.auth.tunneling.disabledSchemes= -Djdk.http.auth.proxying.disabledSchemes=
[INFO] Running com.ankurm.datajpa.ServiceConnectionWithoutGuardFailsToStartTest
17:20:10.044 [main] INFO org.springframework.test.context.support.AnnotationConfigContextLoaderUtils -- Could not detect default configuration classes for test class [com.ankurm.datajpa.ServiceConnectionWithoutGuardFailsToStartTest]: ServiceConnectionWithoutGuardFailsToStartTest does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
17:20:10.211 [main] INFO org.testcontainers.images.PullPolicy -- Image pull policy will be performed by: DefaultPullPolicy()
17:20:10.213 [main] INFO org.testcontainers.utility.ImageNameSubstitutor -- Image name substitution will be performed by: DefaultImageNameSubstitutor (composite of 'ConfigurationFileImageNameSubstitutor' and 'PrefixingImageNameSubstitutor')
17:20:10.253 [main] INFO org.testcontainers.DockerClientFactory -- Testcontainers version: 2.0.5
WARNING: A restricted method in java.lang.System has been called
WARNING: java.lang.System::load has been called by com.sun.jna.Native in an unnamed module (file:/root/.m2/repository/net/java/dev/jna/jna/5.18.1/jna-5.18.1.jar)
WARNING: Use --enable-native-access=ALL-UNNAMED to avoid a warning for callers in this module
WARNING: Restricted methods will be blocked in a future release unless native access is enabled
17:20:10.569 [main] INFO org.testcontainers.dockerclient.DockerMachineClientProviderStrategy -- docker-machine executable was not found on PATH ([/opt/jdk-25/bin, /home/claude/.npm-global/bin, /nix/var/nix/profiles/default/bin, /root/.local/bin, /root/.cargo/bin, /usr/local/go/bin, /opt/node22/bin, /opt/maven/bin, /opt/gradle/bin, /opt/rbenv/bin, /root/.bun/bin, /usr/local/sbin, /usr/local/bin, /usr/sbin, /usr/bin, /sbin, /bin])
17:20:10.570 [main] ERROR org.testcontainers.dockerclient.DockerClientProviderStrategy -- Could not find a valid Docker environment. Please check configuration. Attempted configurations were:
UnixSocketClientProviderStrategy: failed with exception InvalidConfigurationException (Could not find unix domain socket). Root cause NoSuchFileException (/var/run/docker.sock)
DockerDesktopClientProviderStrategy: failed with exception NullPointerException (Cannot invoke "java.nio.file.Path.toString()" because the return value of "org.testcontainers.dockerclient.DockerDesktopClientProviderStrategy.getSocketPath()" is null)As no valid configuration was found, execution cannot continue.
See https://java.testcontainers.org/on_failure.html for more details.
17:20:10.586 [main] INFO org.springframework.test.context.support.AnnotationConfigContextLoaderUtils -- Could not detect default configuration classes for test class [com.ankurm.datajpa.ServiceConnectionWithoutGuardFailsToStartTest]: ServiceConnectionWithoutGuardFailsToStartTest does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
17:20:10.617 [main] INFO org.testcontainers.DockerClientFactory -- Testcontainers version: 2.0.5
[ERROR] Tests run: 1, Failures: 0, Errors: 1, Skipped: 0, Time elapsed: 0.877 s <<< FAILURE! -- in com.ankurm.datajpa.ServiceConnectionWithoutGuardFailsToStartTest
[ERROR] com.ankurm.datajpa.ServiceConnectionWithoutGuardFailsToStartTest -- Time elapsed: 0.877 s <<< ERROR!
org.testcontainers.containers.ContainerFetchException: Can't get Docker image: RemoteDockerImage(imageName=postgres:17, imagePullPolicy=DefaultPullPolicy(), imageNameSubstitutor=org.testcontainers.utility.ImageNameSubstitutor$LogWrappedImageNameSubstitutor@27f1bbe0)
at org.testcontainers.containers.GenericContainer.getDockerImageName(GenericContainer.java:1308)
at org.springframework.boot.testcontainers.service.connection.ServiceConnectionContextCustomizerFactory.createSource(ServiceConnectionContextCustomizerFactory.java:90)
at org.springframework.boot.testcontainers.service.connection.ServiceConnectionContextCustomizerFactory.lambda$collectSources$1(ServiceConnectionContextCustomizerFactory.java:65)
at org.springframework.core.annotation.TypeMappedAnnotations$AggregatesSpliterator.tryAdvance(TypeMappedAnnotations.java:591)
at org.springframework.core.annotation.TypeMappedAnnotations$AggregatesSpliterator.tryAdvance(TypeMappedAnnotations.java:558)
at java.base/java.util.Spliterator.forEachRemaining(Spliterator.java:332)
at java.base/java.util.stream.ReferencePipeline$Head.forEach(ReferencePipeline.java:803)
at org.springframework.boot.testcontainers.service.connection.ServiceConnectionContextCustomizerFactory.lambda$collectSources$0(ServiceConnectionContextCustomizerFactory.java:65)
at org.springframework.util.ReflectionUtils.doWithLocalFields(ReflectionUtils.java:691)
at org.springframework.boot.testcontainers.service.connection.ServiceConnectionContextCustomizerFactory.collectSources(ServiceConnectionContextCustomizerFactory.java:62)
at org.springframework.boot.testcontainers.service.connection.ServiceConnectionContextCustomizerFactory.createContextCustomizer(ServiceConnectionContextCustomizerFactory.java:54)
at org.springframework.test.context.support.AbstractTestContextBootstrapper.getContextCustomizers(AbstractTestContextBootstrapper.java:420)
at org.springframework.test.context.support.AbstractTestContextBootstrapper.buildMergedContextConfiguration(AbstractTestContextBootstrapper.java:393)
at org.springframework.test.context.support.AbstractTestContextBootstrapper.buildDefaultMergedContextConfiguration(AbstractTestContextBootstrapper.java:268)
at org.springframework.test.context.support.AbstractTestContextBootstrapper.buildMergedContextConfiguration(AbstractTestContextBootstrapper.java:226)
at org.springframework.test.context.support.AbstractTestContextBootstrapper.buildTestContext(AbstractTestContextBootstrapper.java:117)
at org.springframework.boot.test.context.SpringBootTestContextBootstrapper.buildTestContext(SpringBootTestContextBootstrapper.java:102)
at org.springframework.test.context.TestContextManager.<init>(TestContextManager.java:142)
at org.springframework.test.context.TestContextManager.<init>(TestContextManager.java:126)
at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:328)
at org.springframework.test.context.junit.jupiter.SpringExtension.getTestContextManager(SpringExtension.java:466)
at org.springframework.test.context.junit.jupiter.SpringExtension.beforeAll(SpringExtension.java:209)
at java.base/java.util.ArrayList.forEach(ArrayList.java:1604)
Suppressed: org.testcontainers.containers.ContainerFetchException: Can't get Docker image: RemoteDockerImage(imageName=postgres:17, imagePullPolicy=DefaultPullPolicy(), imageNameSubstitutor=org.testcontainers.utility.ImageNameSubstitutor$LogWrappedImageNameSubstitutor@27f1bbe0)
at org.testcontainers.containers.GenericContainer.getDockerImageName(GenericContainer.java:1308)
at org.springframework.boot.testcontainers.service.connection.ServiceConnectionContextCustomizerFactory.createSource(ServiceConnectionContextCustomizerFactory.java:90)
at org.springframework.boot.testcontainers.service.connection.ServiceConnectionContextCustomizerFactory.lambda$collectSources$1(ServiceConnectionContextCustomizerFactory.java:65)
at org.springframework.core.annotation.TypeMappedAnnotations$AggregatesSpliterator.tryAdvance(TypeMappedAnnotations.java:591)
at org.springframework.core.annotation.TypeMappedAnnotations$AggregatesSpliterator.tryAdvance(TypeMappedAnnotations.java:558)
at java.base/java.util.Spliterator.forEachRemaining(Spliterator.java:332)
at java.base/java.util.stream.ReferencePipeline$Head.forEach(ReferencePipeline.java:803)
at org.springframework.boot.testcontainers.service.connection.ServiceConnectionContextCustomizerFactory.lambda$collectSources$0(ServiceConnectionContextCustomizerFactory.java:65)
at org.springframework.util.ReflectionUtils.doWithLocalFields(ReflectionUtils.java:691)
at org.springframework.boot.testcontainers.service.connection.ServiceConnectionContextCustomizerFactory.collectSources(ServiceConnectionContextCustomizerFactory.java:62)
at org.springframework.boot.testcontainers.service.connection.ServiceConnectionContextCustomizerFactory.createContextCustomizer(ServiceConnectionContextCustomizerFactory.java:54)
at org.springframework.test.context.support.AbstractTestContextBootstrapper.getContextCustomizers(AbstractTestContextBootstrapper.java:420)
at org.springframework.test.context.support.AbstractTestContextBootstrapper.buildMergedContextConfiguration(AbstractTestContextBootstrapper.java:393)
at org.springframework.test.context.support.AbstractTestContextBootstrapper.buildDefaultMergedContextConfiguration(AbstractTestContextBootstrapper.java:268)
at org.springframework.test.context.support.AbstractTestContextBootstrapper.buildMergedContextConfiguration(AbstractTestContextBootstrapper.java:226)
at org.springframework.test.context.support.AbstractTestContextBootstrapper.buildTestContext(AbstractTestContextBootstrapper.java:117)
at org.springframework.boot.test.context.SpringBootTestContextBootstrapper.buildTestContext(SpringBootTestContextBootstrapper.java:102)
at org.springframework.test.context.TestContextManager.<init>(TestContextManager.java:142)
at org.springframework.test.context.TestContextManager.<init>(TestContextManager.java:126)
at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:328)
at org.springframework.test.context.junit.jupiter.SpringExtension.getTestContextManager(SpringExtension.java:466)
at org.springframework.test.context.junit.jupiter.SpringExtension.afterAll(SpringExtension.java:220)
... 1 more
Caused by: java.lang.IllegalStateException: Previous attempts to find a Docker environment failed. Will not retry. Please see logs and check configuration
at org.testcontainers.dockerclient.DockerClientProviderStrategy.getFirstValidStrategy(DockerClientProviderStrategy.java:229)
at org.testcontainers.DockerClientFactory.getOrInitializeStrategy(DockerClientFactory.java:154)
at org.testcontainers.DockerClientFactory.client(DockerClientFactory.java:196)
at org.testcontainers.images.LocalImagesCache.get(LocalImagesCache.java:31)
at org.testcontainers.images.AbstractImagePullPolicy.shouldPull(AbstractImagePullPolicy.java:18)
at org.testcontainers.images.RemoteDockerImage.resolve(RemoteDockerImage.java:79)
at org.testcontainers.images.RemoteDockerImage.resolve(RemoteDockerImage.java:35)
at org.testcontainers.utility.LazyFuture.getResolvedValue(LazyFuture.java:20)
at org.testcontainers.utility.LazyFuture.get(LazyFuture.java:41)
at org.testcontainers.containers.GenericContainer.getDockerImageName(GenericContainer.java:1306)
... 22 more
Caused by: java.lang.IllegalStateException: Could not find a valid Docker environment. Please see logs and check configuration
at org.testcontainers.dockerclient.DockerClientProviderStrategy.lambda$getFirstValidStrategy$7(DockerClientProviderStrategy.java:274)
at java.base/java.util.Optional.orElseThrow(Optional.java:403)
at org.testcontainers.dockerclient.DockerClientProviderStrategy.getFirstValidStrategy(DockerClientProviderStrategy.java:265)
at org.testcontainers.DockerClientFactory.getOrInitializeStrategy(DockerClientFactory.java:154)
at org.testcontainers.DockerClientFactory.client(DockerClientFactory.java:196)
at org.testcontainers.images.LocalImagesCache.get(LocalImagesCache.java:31)
at org.testcontainers.images.AbstractImagePullPolicy.shouldPull(AbstractImagePullPolicy.java:18)
at org.testcontainers.images.RemoteDockerImage.resolve(RemoteDockerImage.java:79)
at org.testcontainers.images.RemoteDockerImage.resolve(RemoteDockerImage.java:35)
at org.testcontainers.utility.LazyFuture.getResolvedValue(LazyFuture.java:20)
at org.testcontainers.utility.LazyFuture.get(LazyFuture.java:41)
at org.testcontainers.containers.GenericContainer.getDockerImageName(GenericContainer.java:1306)
... 22 more
[INFO]
[INFO] Results:
[INFO]
[ERROR] Errors:
[ERROR] ServiceConnectionWithoutGuardFailsToStartTest ? ContainerFetch Can't get Docker image: RemoteDockerImage(imageName=postgres:17, imagePullPolicy=DefaultPullPolicy(), imageNameSubstitutor=org.testcontainers.utility.ImageNameSubstitutor$LogWrappedImageNameSubstitutor@27f1bbe0)
[INFO]
[ERROR] Tests run: 1, Failures: 0, Errors: 1, Skipped: 0
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD FAILURE
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 4.242 s
[INFO] Finished at: 2026-10-08T17:20:10+05:30
[INFO] ------------------------------------------------------------------------
[ERROR] Failed to execute goal org.apache.maven.plugins:maven-surefire-plugin:3.5.6:test (default-test) on project data-jpa-testcontainers:
[ERROR]
[ERROR] See /home/claude/spring-boot-demo/data-jpa-testcontainers/target/surefire-reports for the individual test results.
[ERROR] See dump files (if any exist) [date].dump, [date]-jvmRun[N].dump and [date].dumpstream.
[ERROR] -> [Help 1]
[ERROR]
[ERROR] To see the full stack trace of the errors, re-run Maven with the -e switch.
[ERROR] Re-run Maven using the -X switch to enable full debug logging.
[ERROR]
[ERROR] For more information about the errors and possible solutions, please read the following articles:
[ERROR] [Help 1] http://cwiki.apache.org/confluence/display/MAVEN/MojoFailureException
@@ -0,0 +1,56 @@
Picked up JAVA_TOOL_OPTIONS: -Djavax.net.ssl.trustStore=/etc/ssl/certs/java/cacerts -Djavax.net.ssl.trustStoreType=JKS -Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort=34689 -Dhttp.nonProxyHosts=localhost|127.0.0.1|::1|127.*|0.*|::|169.254.*|api.anthropic.com|api-staging.anthropic.com|api-pr-preview.anthropic.com|mcp-proxy.anthropic.com|mcp-proxy-staging.anthropic.com|registry.npmjs.org|jsr.io|npm.jsr.io|pypi.org|files.pythonhosted.org|index.crates.io|proxy.golang.org|host.docker.internal|10.*|172.16.*|172.17.*|172.18.*|172.19.*|172.20.*|172.21.*|172.22.*|172.23.*|172.24.*|172.25.*|172.26.*|172.27.*|172.28.*|172.29.*|172.30.*|172.31.*|192.168.*|100.64.0.0/10|*.svc.cluster.local|*.svc.cluster.local -Djdk.http.auth.tunneling.disabledSchemes= -Djdk.http.auth.proxying.disabledSchemes=
WARNING: A terminally deprecated method in sun.misc.Unsafe has been called
WARNING: sun.misc.Unsafe::staticFieldBase has been called by com.google.inject.internal.aop.HiddenClassDefiner (file:/opt/maven/lib/guice-5.1.0-classes.jar)
WARNING: Please consider reporting this to the maintainers of class com.google.inject.internal.aop.HiddenClassDefiner
WARNING: sun.misc.Unsafe::staticFieldBase will be removed in a future release
[INFO] Scanning for projects...
[INFO]
[INFO] -----------------< com.ankurm:data-jpa-testcontainers >-----------------
[INFO] Building data-jpa-testcontainers 1.0.0
[INFO] from pom.xml
[INFO] --------------------------------[ jar ]---------------------------------
[INFO]
[INFO] --- resources:3.5.0:resources (default-resources) @ data-jpa-testcontainers ---
[INFO] Copying 0 resource from src/main/resources to target/classes
[INFO] Copying 1 resource from src/main/resources to target/classes
[INFO]
[INFO] --- compiler:3.15.0:compile (default-compile) @ data-jpa-testcontainers ---
[INFO] Nothing to compile - all classes are up to date.
[INFO]
[INFO] --- resources:3.5.0:testResources (default-testResources) @ data-jpa-testcontainers ---
[INFO] skip non existing resourceDirectory /home/claude/spring-boot-demo/data-jpa-testcontainers/src/test/resources
[INFO]
[INFO] --- compiler:3.15.0:testCompile (default-testCompile) @ data-jpa-testcontainers ---
[INFO] Nothing to compile - all classes are up to date.
[INFO]
[INFO] --- surefire:3.5.6:test (default-test) @ data-jpa-testcontainers ---
[INFO] Using auto detected provider org.apache.maven.surefire.junitplatform.JUnitPlatformProvider
[INFO]
[INFO] -------------------------------------------------------
[INFO] T E S T S
[INFO] -------------------------------------------------------
Picked up JAVA_TOOL_OPTIONS: -Djavax.net.ssl.trustStore=/etc/ssl/certs/java/cacerts -Djavax.net.ssl.trustStoreType=JKS -Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort=34689 -Dhttp.nonProxyHosts=localhost|127.0.0.1|::1|127.*|0.*|::|169.254.*|api.anthropic.com|api-staging.anthropic.com|api-pr-preview.anthropic.com|mcp-proxy.anthropic.com|mcp-proxy-staging.anthropic.com|registry.npmjs.org|jsr.io|npm.jsr.io|pypi.org|files.pythonhosted.org|index.crates.io|proxy.golang.org|host.docker.internal|10.*|172.16.*|172.17.*|172.18.*|172.19.*|172.20.*|172.21.*|172.22.*|172.23.*|172.24.*|172.25.*|172.26.*|172.27.*|172.28.*|172.29.*|172.30.*|172.31.*|192.168.*|100.64.0.0/10|*.svc.cluster.local|*.svc.cluster.local -Djdk.http.auth.tunneling.disabledSchemes= -Djdk.http.auth.proxying.disabledSchemes=
17:20:33.981 [main] INFO org.testcontainers.DockerClientFactory -- Testcontainers version: 2.0.5
WARNING: A restricted method in java.lang.System has been called
WARNING: java.lang.System::load has been called by com.sun.jna.Native in an unnamed module (file:/root/.m2/repository/net/java/dev/jna/jna/5.18.1/jna-5.18.1.jar)
WARNING: Use --enable-native-access=ALL-UNNAMED to avoid a warning for callers in this module
WARNING: Restricted methods will be blocked in a future release unless native access is enabled
17:20:34.343 [main] INFO org.testcontainers.dockerclient.DockerMachineClientProviderStrategy -- docker-machine executable was not found on PATH ([/opt/jdk-25/bin, /home/claude/.npm-global/bin, /nix/var/nix/profiles/default/bin, /root/.local/bin, /root/.cargo/bin, /usr/local/go/bin, /opt/node22/bin, /opt/maven/bin, /opt/gradle/bin, /opt/rbenv/bin, /root/.bun/bin, /usr/local/sbin, /usr/local/bin, /usr/sbin, /usr/bin, /sbin, /bin])
17:20:34.345 [main] ERROR org.testcontainers.dockerclient.DockerClientProviderStrategy -- Could not find a valid Docker environment. Please check configuration. Attempted configurations were:
UnixSocketClientProviderStrategy: failed with exception InvalidConfigurationException (Could not find unix domain socket). Root cause NoSuchFileException (/var/run/docker.sock)
DockerDesktopClientProviderStrategy: failed with exception NullPointerException (Cannot invoke "java.nio.file.Path.toString()" because the return value of "org.testcontainers.dockerclient.DockerDesktopClientProviderStrategy.getSocketPath()" is null)As no valid configuration was found, execution cannot continue.
See https://java.testcontainers.org/on_failure.html for more details.
[INFO] Running com.ankurm.datajpa.ServiceConnectionSkipsCleanlyTest
[WARNING] Tests run: 1, Failures: 0, Errors: 0, Skipped: 1, Time elapsed: 0.059 s -- in com.ankurm.datajpa.ServiceConnectionSkipsCleanlyTest
[INFO]
[INFO] Results:
[INFO]
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 1
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 3.721 s
[INFO] Finished at: 2026-10-08T17:20:34+05:30
[INFO] ------------------------------------------------------------------------
@@ -0,0 +1,40 @@
$ javap -v org/springframework/boot/jdbc/test/autoconfigure/AutoConfigureJdbc.class
# unzipped from spring-boot-jdbc-test-4.1.1.jar
RuntimeVisibleAnnotations:
0: #10(#11=[e#12.#13])
java.lang.annotation.Target(
value=[Ljava/lang/annotation/ElementType;.TYPE]
)
1: #14(#11=e#15.#16)
java.lang.annotation.Retention(
value=Ljava/lang/annotation/RetentionPolicy;.RUNTIME
)
2: #17()
java.lang.annotation.Documented
3: #18()
java.lang.annotation.Inherited
4: #19()
org.springframework.boot.autoconfigure.ImportAutoConfiguration
5: #20()
org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureDataSourceInitialization
$ find . -iname '*DataSourceInitialization*' # inside spring-boot-jdbc-test-4.1.1.jar
./META-INF/spring/org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureDataSourceInitialization.imports
$ cat META-INF/spring/org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureDataSourceInitialization.imports
org.springframework.boot.jdbc.autoconfigure.DataSourceInitializationAutoConfiguration
# the SAME resource path, inside spring-boot-flyway-4.1.1.jar instead:
$ cat META-INF/spring/org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureDataSourceInitialization.imports
org.springframework.boot.flyway.autoconfigure.FlywayAutoConfiguration
$ javap -v org/springframework/boot/flyway/autoconfigure/FlywayAutoConfiguration.class # unzipped from spring-boot-flyway-4.1.1.jar
#41 = Utf8 Lorg/springframework/boot/autoconfigure/condition/ConditionalOnClass;
#42 = Utf8 value
#43 = Utf8 Lorg/flywaydb/core/Flyway;
#44 = Utf8 Lorg/springframework/context/annotation/Conditional;
--
org.springframework.boot.autoconfigure.condition.ConditionalOnClass(
value=[class Lorg/flywaydb/core/Flyway;]
)
2: #44(#42=[c#45])
+149
View File
@@ -0,0 +1,149 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>data-jpa-testcontainers</artifactId>
<version>1.0.0</version>
<name>data-jpa-testcontainers</name>
<description>@DataJpaTest with Testcontainers @ServiceConnection on Spring Boot 4.1.1 / Testcontainers 2.0.5:
what the default embedded-database substitution actually does, what @ServiceConnection wires in its
place, and what each one looks like when something is missing.</description>
<packaging>jar</packaging>
<properties>
<java.version>25</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<!-- Flyway 12 split its database-specific parsers out of flyway-core (confirmed in the
db-migrations-flyway-liquibase module's findings). This repo's migration is run twice in
this article: once for real against H2 (no extra dependency needed, H2 support is built
into flyway-core) and once, in the real-Postgres test classes, against whatever
PostgreSQLContainer provides — which needs this dialect module on the classpath even
though that test class cannot start a container in this sandbox. -->
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
<!-- flyway-core on the classpath is NOT enough to get FlywayAutoConfiguration — that class
lives in its own starter, a further instance of Boot 4's per-technology module split
(confirmed by comparing against the db-migrations-flyway-liquibase module, which needs the
exact same starter). This is what actually makes Flyway run under @DataJpaTest by default
in this module: @AutoConfigureJdbc's @AutoConfigureDataSourceInitialization meta-annotation
merges a META-INF/spring/...AutoConfigureDataSourceInitialization.imports file from every
jar that ships one, and spring-boot-flyway (pulled in by this starter) is one of them —
confirmed with javap and unzip, see FlywayRunsByDefaultTest's Javadoc. Test scope only:
this article's main app never runs Flyway itself outside the test slice. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-flyway</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- @DataJpaTest itself: relocated out of spring-boot-test-autoconfigure in Boot 4.1 into its
own module. Confirmed by unzipping this jar: the only class in it is DataJpaTest plus its
bootstrapper and type-exclude filter — TestEntityManager and AutoConfigureTestDatabase are
NOT in here, they come in transitively from the two dependencies below. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-data-jpa-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Pulled in transitively by spring-boot-data-jpa-test above (spring-boot-jpa-test for
TestEntityManager, spring-boot-jdbc-test for AutoConfigureTestDatabase/TestDatabaseAutoConfiguration)
— listed explicitly here only so the module table in the README has something to point at;
removing these two lines changes nothing on the classpath. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-jpa-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-jdbc-test</artifactId>
<scope>test</scope>
</dependency>
<!-- @ServiceConnection itself — unaffected by the Testcontainers 2.0 package moves below,
because it is typed against org.testcontainers.containers.JdbcDatabaseContainer, which did
not move. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<!-- Testcontainers 2.0.5. PostgreSQLContainer lives at org.testcontainers.postgresql in this
version; org.testcontainers.containers.PostgreSQLContainer still exists as a working
@Deprecated subclass, in this SAME jar (confirmed with `unzip -l`: both classes are inside
testcontainers-postgresql-2.0.5.jar, not split across two artifacts). -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-postgresql</artifactId>
<scope>test</scope>
</dependency>
<!-- The JUnit 5 extension's artifact was renamed from junit-jupiter to
testcontainers-junit-jupiter in 2.0; its package, org.testcontainers.junit.jupiter, did not
change. This version also ships a new @EnabledIfDockerAvailable condition, used below. -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<finalName>data-jpa-testcontainers</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<!-- This class exists to demonstrate a real startup FAILURE on purpose: a container with
no guard against a missing Docker daemon. It is run individually with
`mvn test -Dtest=ServiceConnectionWithoutGuardFailsToStartTest` to capture that
failure for docs/output — excluded here so the module's default `mvn test` build
stays green. -->
<excludes>
<exclude>**/ServiceConnectionWithoutGuardFailsToStartTest.java</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,12 @@
package com.ankurm.datajpa;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class DataJpaTestcontainersApplication {
public static void main(String[] args) {
SpringApplication.run(DataJpaTestcontainersApplication.class, args);
}
}
@@ -0,0 +1,49 @@
package com.ankurm.datajpa;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
/**
* A deliberately boring entity. The point of this module is not the domain model -- it is what
* backs the DataSource underneath it, which is why every field here is the simplest type that
* will do the job.
*
* See the "@DataJpaTest with Testcontainers @ServiceConnection" article on ankurm.com for the
* chapter this entity supports.
*/
@Entity
@Table(name = "product")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String sku;
private String name;
protected Product() {
// JPA
}
public Product(String sku, String name) {
this.sku = sku;
this.name = name;
}
public Long getId() {
return id;
}
public String getSku() {
return sku;
}
public String getName() {
return name;
}
}
@@ -0,0 +1,10 @@
package com.ankurm.datajpa;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface ProductRepository extends JpaRepository<Product, Long> {
Optional<Product> findBySku(String sku);
}
@@ -0,0 +1,5 @@
create table product (
id bigint generated by default as identity primary key,
sku varchar(64) not null unique,
name varchar(255) not null
);
@@ -0,0 +1,58 @@
package com.ankurm.datajpa;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager;
import javax.sql.DataSource;
import java.sql.Connection;
import static org.assertj.core.api.Assertions.assertThat;
/**
* No @ServiceConnection, no Testcontainers import anywhere in this file. Plain @DataJpaTest,
* exactly as most tutorials show it.
*
* What it actually runs against, and why the two tests below pass or fail in isolation from each
* other, is the subject of the article's second and third sections.
*/
@DataJpaTest
class DefaultReplacementTest {
@Autowired
private TestEntityManager entityManager;
@Autowired
private ProductRepository productRepository;
@Autowired
private DataSource dataSource;
@Test
void whateverIsBehindThisDataSourceIsNotPostgres() throws Exception {
String productName;
try (Connection connection = dataSource.getConnection()) {
productName = connection.getMetaData().getDatabaseProductName();
}
System.out.println("JDBC reports database product name: " + productName);
assertThat(productName).isNotEqualTo("PostgreSQL");
}
@Test
void insertedRowIsVisibleWithinThisTest() {
entityManager.persistAndFlush(new Product("SKU-1", "Widget"));
assertThat(productRepository.findBySku("SKU-1")).isPresent();
assertThat(productRepository.count()).isEqualTo(1);
}
@Test
void previousTestsRowIsGoneInThisOne() {
// If @DataJpaTest did not roll each test back, this would see the row the previous test
// method inserted. It does not -- each test method gets its own transaction, rolled back
// when the method returns, regardless of test execution order.
assertThat(productRepository.count()).isZero();
assertThat(productRepository.findBySku("SKU-1")).isEmpty();
}
}
@@ -0,0 +1,59 @@
package com.ankurm.datajpa;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.flyway.autoconfigure.FlywayAutoConfiguration;
import org.springframework.context.ApplicationContext;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.Statement;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Older advice for this exact situation (including an earlier draft of this module, and more
* than one blog post this article will not link to) says to add
* {@code @ImportAutoConfiguration(FlywayAutoConfiguration.class)} yourself to get Flyway running
* inside a {@code @DataJpaTest}. As {@link FlywayRunsByDefaultTest} now shows, that is no longer
* necessary on Spring Boot 4.1 -- the import already happens on its own, by way of
* {@code @AutoConfigureJdbc}'s own {@code @AutoConfigureDataSourceInitialization} meta-annotation.
*
* <p>Adding the explicit import anyway is not harmful. {@code @ImportAutoConfiguration} is
* backed by ordinary Spring auto-configuration machinery: the same fully-qualified class name,
* however many times it is reached (once through the transitive meta-annotation walk, once
* through this class's own explicit annotation), is only ever registered once. This test proves
* that directly rather than asserting it from the mechanism alone: exactly one {@code flyway}
* bean, and exactly one applied migration -- not two -- with the redundant import sitting right
* there on the class.
*/
@DataJpaTest
@ImportAutoConfiguration(FlywayAutoConfiguration.class)
class ExplicitFlywayImportIsRedundantTest {
@Autowired
private DataSource dataSource;
@Autowired
private ApplicationContext ctx;
@Test
void redundantImportStillProducesExactlyOneFlywayBean() {
assertThat(ctx.getBeanNamesForType(org.flywaydb.core.Flyway.class)).hasSize(1);
}
@Test
void redundantImportDoesNotReapplyTheMigration() throws Exception {
try (Connection connection = dataSource.getConnection();
Statement statement = connection.createStatement()) {
var rs = statement.executeQuery(
"select count(*) from \"flyway_schema_history\" where \"installed_rank\" >= 0");
assertThat(rs.next()).isTrue();
int realMigrationRows = rs.getInt(1);
System.out.println("flyway_schema_history real migration rows (rank >= 0): " + realMigrationRows);
assertThat(realMigrationRows).isEqualTo(1);
}
}
}
@@ -0,0 +1,92 @@
package com.ankurm.datajpa;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.context.ApplicationContext;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.Statement;
import static org.assertj.core.api.Assertions.assertThat;
/**
* This module has a real Flyway migration, {@code V1__create_product.sql}, and flyway-core
* (plus the test-scoped {@code spring-boot-starter-flyway} -- see the module README for why that
* second one matters) is on the classpath. A plain {@code @DataJpaTest}, with no extra
* annotation anywhere on this class, runs that migration by default.
*
* <p>An earlier draft of this module asserted the opposite, on the strength of reading
* {@code @DataJpaTest}'s four documented meta-annotations ({@code @AutoConfigureDataJpa},
* {@code @AutoConfigureJdbc}, {@code @AutoConfigureTestDatabase},
* {@code @AutoConfigureTestEntityManager}) and finding that none of their four
* {@code .imports} manifests mentions Flyway or Liquibase. That reading is correct and
* incomplete: {@code AutoConfigureJdbc.class} is itself meta-annotated with a fifth annotation,
* {@code @AutoConfigureDataSourceInitialization} (new in Boot 4.0; confirmed with
* {@code javap -v org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureJdbc} against
* {@code spring-boot-jdbc-test-4.1.1.jar}), and Spring's own
* {@code ImportAutoConfigurationImportSelector} walks the entire meta-annotation tree
* transitively -- every annotation, and every meta-annotation on those -- collecting every class
* in that tree that is itself annotated with {@code @ImportAutoConfiguration}. That fifth
* annotation is one of them.
*
* <p>{@code @AutoConfigureDataSourceInitialization}'s own {@code .imports} resource is loaded via
* {@code ClassLoader.getResources()}, which returns every file at that path across every jar on
* the classpath and merges them -- not a single-file lookup. Three jars ship a file at the exact
* same resource path
* ({@code META-INF/spring/org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureDataSourceInitialization.imports}):
* {@code spring-boot-jdbc-test} contributes {@code DataSourceInitializationAutoConfiguration},
* {@code spring-boot-flyway} contributes {@code FlywayAutoConfiguration}, and
* {@code spring-boot-liquibase} contributes {@code LiquibaseAutoConfiguration}. Confirmed by
* unzipping both jars actually on this classpath and reading the file; the Liquibase side was
* not independently re-run here (no Liquibase module in this repo) but the same mechanism applies
* to it by construction. {@code FlywayAutoConfiguration} is still gated by the ordinary
* {@code @ConditionalOnClass(Flyway.class)} -- it only activates once flyway-core is genuinely on
* the classpath, which is the one part of the old draft's observation that was never wrong, just
* misattributed to the wrong mechanism.
*
* <p>See {@link ExplicitFlywayImportIsRedundantTest} for what this means for the (now
* unnecessary, but harmless) advice to {@code @ImportAutoConfiguration(FlywayAutoConfiguration.class)}
* yourself.
*/
@DataJpaTest
class FlywayRunsByDefaultTest {
@Autowired
private DataSource dataSource;
@Autowired
private ApplicationContext ctx;
@Test
void flywayAutoConfigurationBeansArePresentWithNoExplicitImportAnywhere() {
assertThat(ctx.getBeanNamesForType(org.flywaydb.core.Flyway.class)).isNotEmpty();
assertThat(ctx.containsBean("flywayInitializer")).isTrue();
}
@Test
void theMigrationActuallyRanAgainstTheSubstitutedDatabase() throws Exception {
// Flyway's own history table always carries a bookkeeping row at installed_rank = -1
// ("<< Flyway Schema History table created >>") ahead of the real migration rows, and
// both the table name and every column name are stored quoted and lowercase -- H2's
// default unquoted-identifier folding (to uppercase) does not apply to any of them.
try (Connection connection = dataSource.getConnection();
Statement statement = connection.createStatement();
ResultSet rs = statement.executeQuery(
"select \"installed_rank\", \"version\", \"description\", \"success\" "
+ "from \"flyway_schema_history\" where \"installed_rank\" = 1")) {
assertThat(rs.next()).isTrue();
String version = rs.getString(2);
String description = rs.getString(3);
boolean success = rs.getBoolean(4);
System.out.println("flyway_schema_history row: version=" + version
+ " description=" + description + " success=" + success);
assertThat(version).isEqualTo("1");
assertThat(description).isEqualTo("create product");
assertThat(success).isTrue();
assertThat(rs.next()).isFalse();
}
}
}
@@ -0,0 +1,51 @@
package com.ankurm.datajpa;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import javax.sql.DataSource;
import java.sql.Connection;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase.Replace;
/**
* The actually-surprising half of this trap. The intuition is that
* {@code @AutoConfigureTestDatabase(replace = Replace.NONE)} turns off the embedded substitution,
* so without a real connection configured anywhere this test should fail to start -- that was this
* class's original name and premise, until running it against this sandbox's lack of Docker proved
* that assumption wrong.
*
* What {@code replace = Replace.NONE} actually turns off is narrower: only
* {@code TestDatabaseAutoConfiguration}'s own bean-post-processing step, the one that replaces an
* already-configured production DataSource bean. It does nothing to the ordinary
* {@code DataSourceAutoConfiguration}, which auto-provisions an embedded database whenever no
* {@code spring.datasource.url} is set and an embedded driver is on the classpath -- completely
* independent machinery, with no awareness of the test slice at all. There is no
* {@code @ServiceConnection} in this class, and no connection properties anywhere. Replace.NONE is
* set. It still silently gets H2, with no warning that anything was substituted, because nothing
* Replace.NONE knows about was. The real log line is in
* {@code docs/output/00-full-test-run.txt}: a HikariDataSource backed by {@code jdbc:h2:mem:...},
* never the "Replacing 'dataSource' DataSource bean with embedded version" line that
* {@link DefaultReplacementTest} produces.
*/
@DataJpaTest
@AutoConfigureTestDatabase(replace = Replace.NONE)
class NoSubstitutionStillSilentlyUsesH2Test {
@Autowired
private DataSource dataSource;
@Test
void replaceNoneIsNotTheSameAsARealConnection() throws Exception {
String productName;
try (Connection connection = dataSource.getConnection()) {
productName = connection.getMetaData().getDatabaseProductName();
}
System.out.println("JDBC reports database product name: " + productName
+ " (even with replace = Replace.NONE and no @ServiceConnection anywhere)");
assertThat(productName).isEqualTo("H2");
}
}
@@ -0,0 +1,57 @@
package com.ankurm.datajpa;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.EnabledIfDockerAvailable;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.postgresql.PostgreSQLContainer;
import javax.sql.DataSource;
import java.sql.Connection;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase.Replace;
/**
* This is the article's intended, finished shape: a real Postgres container wired in as the
* DataSource for a @DataJpaTest slice, with the default embedded substitution turned off so the
* real connection is actually used.
*
* {@code @EnabledIfDockerAvailable} is new in Testcontainers 2.0's JUnit 5 extension (confirmed by
* `unzip -l` against the 2.0.5 artifact; it does not exist in 1.x). Without it, this class would
* try to start the container and fail loudly the moment Docker is not reachable -- see
* {@link ServiceConnectionWithoutGuardFailsToStartTest} for exactly what that looks like. With it,
* JUnit reports the whole class as cleanly SKIPPED instead, which is what actually happened the one
* time this class ran for this article: no Docker daemon in the build sandbox, captured in
* {@code docs/output/05-service-connection-skips-cleanly.txt}.
*/
@DataJpaTest
@Testcontainers
@AutoConfigureTestDatabase(replace = Replace.NONE)
@EnabledIfDockerAvailable
class ServiceConnectionSkipsCleanlyTest {
// Unlike the deprecated org.testcontainers.containers.PostgreSQLContainer<SELF extends ...>,
// this new org.testcontainers.postgresql.PostgreSQLContainer is not itself generic (confirmed
// with javap) -- it extends JdbcDatabaseContainer<PostgreSQLContainer> directly. Code ported
// from the old import that wrote `PostgreSQLContainer<?>` needs that type parameter dropped.
@Container
@ServiceConnection
static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:17");
@Autowired
private DataSource dataSource;
@Test
void realDatabaseProductNameIsPostgres() throws Exception {
try (Connection connection = dataSource.getConnection()) {
String productName = connection.getMetaData().getDatabaseProductName();
System.out.println("JDBC reports database product name: " + productName);
assertThat(productName).isEqualTo("PostgreSQL");
}
}
}
@@ -0,0 +1,39 @@
package com.ankurm.datajpa;
import org.junit.jupiter.api.Test;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.postgresql.PostgreSQLContainer;
import static org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase.Replace;
/**
* Identical to {@link ServiceConnectionSkipsCleanlyTest} except for one missing annotation:
* no {@code @EnabledIfDockerAvailable}. NOT part of this module's default `mvn test` run -- see
* the surefire exclude in pom.xml. Run it on its own with:
*
* <pre>mvn test -Dtest=ServiceConnectionWithoutGuardFailsToStartTest</pre>
*
* Without the guard, the Testcontainers JUnit extension tries to start the container regardless of
* what @AutoConfigureTestDatabase says, and that start call is what actually fails. The captured
* failure -- including whichever of Spring Boot's own
* {@code DockerEnvironmentNotFoundFailureAnalyzer} or the raw Testcontainers exception came through
* -- is in {@code docs/output/04-service-connection-without-guard-fails.txt}.
*/
@DataJpaTest
@Testcontainers
@AutoConfigureTestDatabase(replace = Replace.NONE)
class ServiceConnectionWithoutGuardFailsToStartTest {
@Container
@ServiceConnection
static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:17");
@Test
void neverReached() {
// The test body is irrelevant -- container startup fails before this runs.
}
}
+2
View File
@@ -0,0 +1,2 @@
target/
cap/app.log
+25
View File
@@ -0,0 +1,25 @@
# file-transfer
Companion code for **[File Upload and Download in Spring Boot 4: Multipart, Streaming and S3 Presigned URLs](https://ankurm.com/file-upload-download-spring-boot-4-multipart-streaming-s3-presigned-urls/)**.
Spring Boot 4.1.1, Spring Framework 7.0.9, Tomcat 11.0.24, JDK 25, AWS SDK for Java 2.56.0. No Docker needed.
| Area | Code | What the run shows |
|---|---|---|
| Multipart limits | `UploadController`, `UploadErrors`, `MultipartLimitsTest` | the 1 MB / 2 MB limits are enforced by Tomcat, so `MockMvc` accepts a file the real server rejects; the rejected-upload race (`cap/01-limits.txt`) |
| Filenames | `FileStore`, `FilenameAndDownloadTest` | `getOriginalFilename()` is whatever the client sent, including `../../`; the file is stored under a UUID (`cap/02-filename.txt`) |
| Download | `DownloadController` | `Resource` gives `Range`/206/416 for free; `Content-Disposition` encoding (`cap/03-download.txt`) |
| Presigned URLs | `S3PresignService`, `PresignController`, `FakeS3`, `PresignedUrlTest` | what is and is not covered by the signature; expiry; reuse (`cap/04-presign.txt`) |
| Streaming | `StreamController`, `scripts/run-all.sh` | 200 MB download and a 150 MB upload under `-Xmx64m` (`cap/05-streaming.txt`, `cap/06-big-upload.txt`) |
```bash
./scripts/run-all.sh # tests, then the -Xmx64m experiments; rewrites cap/*.txt
```
## What was and was not run
- The presigned URLs are produced by the real AWS SDK presigner and checked by `FakeS3`, a small in-process server
that recomputes the AWS Signature V4 query signature from the published algorithm. That proves the URLs are consistent
with the algorithm as documented. It does **not** prove a real S3 or MinIO server accepts them: this environment has
no Docker and no S3-compatible server, so nothing here was run against one.
- `app.s3.access-key` and `app.s3.secret-key` are the example keys from the AWS documentation. They are not credentials.
+6
View File
@@ -0,0 +1,6 @@
limits: max-file-size=1MB, max-request-size=2MB
real HTTP, one 500 KB file -> status=201 body={"id":"d8d0b4b8-d530-42aa-b005-9e1b747449a7","originalFilename":"a.txt","displayName":"a.txt","size":512000,"declaredContentType":"text/plai...
real HTTP, one 1.5 MB file -> status=413 body={"detail":"The upload is larger than the configured limit (spring.servlet.multipart.max-file-size / max-request-size).","instance":"/files",...
real HTTP, 3 x 900 KB in one request -> status=413 body={"detail":"The upload is larger than the configured limit (spring.servlet.multipart.max-file-size / max-request-size).","instance":"/files",...
real HTTP, one 10 MB file, 10 attempts -> 7 x status=413, 3 x connection error
MockMvc, the same 1.5 MB file -> status=201
+4
View File
@@ -0,0 +1,4 @@
client sent filename=../../etc/cron.d/evil.txt Content-Type=image/png
server response: {"id":"4980219c-b92a-46d4-a5ab-b5d7636fb7f0","originalFilename":"../../etc/cron.d/evil.txt","displayName":"evil.txt","size":5,"declaredContentType":"image/png"}
root.resolve(originalFilename).normalize() stays inside the storage dir: false
client sent a Windows-style path; server response: {"id":"d436e57e-6855-4fff-b5ca-b9dd0fec25c2","originalFilename":"..\\..\\windows\\system32\\evil.dll","displayName":"evil.dll","size":1,"declaredContentType":"text/plain"}
+12
View File
@@ -0,0 +1,12 @@
GET /files/{id}
status=200 body=ABCDEFGHIJKLMNOPQRSTUVWXYZ
content-type: application/octet-stream
content-length: 26
content-disposition: attachment; filename="resume 2026.txt"; filename*=UTF-8''r%C3%A9sum%C3%A9%202026.txt
accept-ranges: bytes
x-content-type-options: nosniff
GET /files/{id} with Range: bytes=10-19
status=206 body=KLMNOPQRST
content-range: bytes 10-19/26
GET /files/{id} with Range: bytes=500-
status=416 content-range: bytes */26
+18
View File
@@ -0,0 +1,18 @@
PUT a URL signed for 1 second, sent after 2.2 seconds -> status=403 <Error><Code>AccessDenied</Code></Error>
POST /presign/upload {"contentType":"image/png"} -> status=200
method=PUT expiresInSeconds=300 headers={"content-type":"image/png"}
url path: http://<fake-s3>/uploads/4dfdf0af-141a-4d1b-9d35-3609a9385791
query: X-Amz-Algorithm=AWS4-HMAC-SHA256
query: X-Amz-Date=20261017T154013Z
query: X-Amz-SignedHeaders=content-type%3Bhost
query: X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20261017%2Fus-east-1%2Fs3%2Faws4_request
query: X-Amz-Expires=300
query: X-Amz-Signature=4e5c56c29980cf521e477119788a0616c392deac4686df7259957af9f1445203
PUT with the signed Content-Type (image/png) -> status=200
PUT the same URL again (presigned URLs are reusable) -> status=200
PUT with a different Content-Type (text/html) -> status=403 <Error><Code>SignatureDoesNotMatch</Code></Error>
PUT with no Content-Type header -> status=403 <Error><Code>SignatureDoesNotMatch</Code></Error>
PUT after changing the last character of the key -> status=403 <Error><Code>SignatureDoesNotMatch</Code></Error>
PUT after raising X-Amz-Expires to 7 days -> status=403 <Error><Code>SignatureDoesNotMatch</Code></Error>
GET presigned URL -> status=200 bytes match upload=true content-disposition=attachment; filename="holiday.png"
disallowed type: POST /presign/upload text/html -> status=400

Some files were not shown because too many files have changed in this diff Show More