Companion code for Zero-Downtime Database Migrations: Expand-Contract in Practice with Spring Boot: a full expand/migrate-writes/migrate-reads/contract sequence run as an actual rolling deploy across two live replicas, with a load generator sending continuous HTTP traffic through all four deploys (99.98% success, every residual error traced to a root cause rather than left unexplained). Findings include a real NOT NULL constraint trap in the expand migration, a backfill-window bug in the read switch, H2's AUTO_SERVER=TRUE single-point-of-failure behavior under a rolling restart, the drain-before-SIGTERM fix needed to close a health-check gap during graceful shutdown, and H2 silently discarding a concurrently committed INSERT during an ALTER TABLE ADD/DROP COLUMN rebuild - confirmed, by primary source, to be an H2-specific behavior rather than a property of the technique itself. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_019Fb7vW8vLyLKngBc4R3huA
db-migrations-expand-contract
Companion code for Zero-Downtime Database Migrations: Expand-Contract in Practice
with Spring Boot
— every claim in that post traces to a test in this module, a real 4-deploy live run,
or a transcript in docs/output/.
Versions
| Component | Version |
|---|---|
| Spring Boot | 4.1.1 |
Flyway (via spring-boot-starter-flyway) |
12.4.0 |
Jackson (via spring-boot-starter-jackson) |
tools.jackson 3.1.5 |
| Database | H2 2.4.240, standalone TCP server mode |
| JDK | 25 (LTS) |
Quickstart
mvn -DskipTests package # builds target/db-migrations-expand-contract-1.0.0.jar
mvn test # regenerates every unit-test transcript in docs/output/
./scripts/run-all.sh # the live exhibit: real 4-deploy rolling sequence + load generator
run-all.sh starts a standalone H2 TCP server, brings up two replicas on Stage 1,
runs all four deploys of the expand-contract sequence as an actual rolling deploy —
schema-only, code-only, schema-only, in the right order — and keeps a load generator
sending real HTTP traffic through the entire thing. It regenerates:
docs/output/11-live-deploy-sequence.txt— the deploy log, phase by phasedocs/output/12-load-generator-summary.txt— total/ok/error counts, by phasedocs/output/13-schema-diagnostics-timeline.txt—/diag/schemaafter each deploy
The four deploys (Spring profile: app.stage)
| Stage | What it does | Deploy kind |
|---|---|---|
| 1 | Reads and writes only email |
(baseline, before this article starts) |
| 2 | Writes both email and email_address; still reads email |
Deploy 2 — code |
| 3 | Writes both columns; reads COALESCE(email_address, email) |
Deploy 3 — code |
| 4 | Reads and writes only email_address |
Deploy 4a — code |
Between stages, two schema-only migrations run with zero application restarts:
Deploy 1 (EXPAND, V2__add_email_address_column.sql) before Stage 2 ships, and
Deploy 4b (CONTRACT, V3__drop_email_column.sql) after every replica is confirmed on
Stage 4. See docs/01-the-problem-and-the-plan.md.
Endpoints
| Endpoint | Purpose |
|---|---|
POST /customers, GET /customers/{id}, PUT /customers/{id}/email |
The API contract that never changes across all four stages — see CustomerController |
GET /diag/schema |
Live column list + row counts — the exhibit that shows the schema actually expanding and contracting. Delete before shipping (see chapter 15) |
POST /admin/drain |
Publishes ReadinessState.REFUSING_TRAFFIC before a graceful shutdown — the preStop-hook pattern. Unauthenticated in this demo — lock down before shipping |
GET /actuator/health, GET /actuator/info |
Boot's own actuator endpoints |
Documentation
- The problem and the plan
- The expand migration
- Why migrations run outside the app
- The dual write
- The read switch
- The NOT NULL trap
- The backfill window bug
- The rolling-window proof
- The contract migration
- What happens if you drop too soon
- The load generator
- The AUTO_SERVER trap
- Graceful shutdown vs. kill -9
- The DDL lock window — the module's central finding
- Production checklist
Captured output
Every number quoted in the post and in the chapters above comes from a committed
transcript in docs/output/. Files 02–10 and 14 are written by a
Transcript helper while the JUnit test that produced them asserts the same numbers —
a transcript going stale fails the build. Files 11–13 come from the live
run-all.sh sequence.
Findings worth the trip
- H2's
AUTO_SERVER=TRUE"shared embedded database" mode makes the first connecting process the de facto server for every other connection — killing that one replica during a rolling restart broke the other replica's "embedded" database entirely. Fixed by running H2 as an independent standalone TCP server (chapter 12). - Expanding a column without relaxing the retired column's
NOT NULLconstraint fails every Stage 4INSERTfrom the first request onward — a genuine bug in this module's own first draft, reproduced and fixed in the same migration (chapter 6). - A naive Stage 3 read of
email_addressalone returnsNULLfor any row a still-live Stage 1 replica wrote during Deploy 2's own rollout window — fixed withCOALESCE(email_address, email)(chapter 7). - H2 implements
ALTER TABLE ADD COLUMNandDROP COLUMNby rebuilding the table, which can silently discard a row committed by a concurrentINSERTwhile the rebuild is mid-scan — with no exception thrown to the inserting connection. Reproduced directly, and confirmed to be an H2-specific behavior rather than a property of the expand-contract technique itself, since PostgreSQL documents both operations as metadata-only (chapter 14 — the module's central finding). - A real rolling restart, with graceful shutdown alone, still produces
ConnectExceptionbursts: the health-checked pool needs time to notice a draining instance before that instance is actually killed. A/admin/drainendpoint that publishesReadinessState.REFUSING_TRAFFICbeforeSIGTERMcloses that gap (chapter 13). - The full live sequence — real HTTP traffic through a real 4-deploy rolling restart — finished at 99.98% success (30,905 of 30,911 requests), with every one of the six residual errors traced to a single, explained root cause rather than left as an unexplained miss (chapter 11).