Files
spring-boot-demo/db-migrations-expand-contract/docs/03-why-migrations-run-outside-the-app.md
T
asmhatreandClaude Sonnet 5 e478eafda3 Add db-migrations-expand-contract: zero-downtime schema migrations proven with a real 4-deploy rolling run
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
2026-09-16 19:22:48 +00:00

3.5 KiB

3. Why migrations run outside the app

← 2. The expand migration · Next: 4. The dual write →

Every other module in this repository lets Spring Boot run Flyway on startup — spring.flyway.enabled=true, migrate-on-boot, the default most tutorials show. This module turns that off:

flyway:
  enabled: false

from application.yml, and instead ships a second, standalone entry point: MigrationCli:

Flyway flyway = Flyway.configure()
        .dataSource(url, "sa", "")
        .locations("classpath:db/migration")
        .target(target)
        .load();
flyway.migrate();

invoked by scripts/migrate.sh:

java -cp "$MODULE_DIR/target/classes:$(cat "$CP_FILE")" \
    com.ankurm.expandcontract.migration.MigrationCli --target="$TARGET"

Why bother, when "migrate on boot" is one line of config? Because "migrate on boot" quietly welds a schema change to an application restart, and expand-contract's whole argument is that those two things need to be independently controllable events:

  • Deploy 1 (expand) and Deploy 4b (contract) run a migration with zero app restarts — every replica keeps serving traffic on its current code the entire time the ALTER TABLE executes. scripts/run-all.sh calls migrate.sh directly for both of these, with no stop-instance.sh / start-instance.sh anywhere nearby.
  • Deploys 2, 3, and 4a are pure code deploys — a rolling restart with --target fixed at whatever the schema already is. No new SQL runs.

If Flyway ran on every boot, a canary replica restarting for an unrelated reason (an OOM, a node reschedule, a routine redeploy of a config value) would silently re-run whatever migrations hadn't executed yet, at a moment nobody chose. Running Flyway from its own process, invoked deliberately by the deploy pipeline (or by hand, as this module's scripts do), means a schema change happens exactly once, at exactly the moment someone decided it should — the same discipline a real CI/CD "run migrations" job step gives you, kept intact here even though this whole sequence runs on one sandbox.

The --target flag is what lets migrate.sh 2 mean "get the schema to exactly V2, no further" — the same spring.flyway.target property TestSupport.migrateTo uses to put a test database at "however far Deploy N has gotten" before exercising CustomerService against it.

Going deeper

  • MigrationCli connects to the same standalone H2 TCP server the app replicas do — chapter 12 covers why that database is its own process rather than something either replica owns.
  • Flyway's own migrate-on-startup vs. separate-migration-step tradeoff is discussed in Flyway's documentation on migrations (nofollow).

← 2. The expand migration · Next: 4. The dual write →