Files
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.2 KiB

4. The dual write

← 3. Why migrations run outside the app · Next: 5. The read switch →

Deploy 2 is the first code deploy in the sequence, and the only thing it changes is what create() and updateEmail() write. Stage 2's SQL in CustomerService writes to both columns:

case 2, 3 -> jdbc.sql("INSERT INTO customers(name, email, email_address) VALUES (?, ?, ?)")
        .param(name).param(email).param(email)
        .update(keyHolder, "id");
case 2, 3 -> jdbc.sql("UPDATE customers SET email = ?, email_address = ? WHERE id = ?")
        .param(newEmail).param(newEmail).param(id).update();

Stage 2 and Stage 3 share this write path — the only difference between them is what they read, covered in chapter 5. That's deliberate: writes have to stay dual for two whole deploys (2 and 3) so that by the time Deploy 4 arrives, every row in the table — regardless of which stage wrote it last — is guaranteed to have both columns populated identically.

DualWriteConsistencyTest confirms both halves of that promise — a fresh create lands in both columns, and an update replaces the value in both, not just one:

-- after create() --
NAME              | EMAIL                 | EMAIL_ADDRESS
------------------+-----------------------+----------------------
Margaret Hamilton | [email protected] | [email protected]

-- after updateEmail() - the old value is gone from BOTH columns, not just one --
NAME              | EMAIL                   | EMAIL_ADDRESS
------------------+-------------------------+------------------------
Margaret Hamilton | [email protected] | [email protected]

Full transcript: docs/output/04-dual-write-consistency.txt.

The second half of that test matters more than it looks. A dual write that only inserts into both columns but updates only one is a much more common bug than it sounds — the update path is usually written later, by someone who's already stopped thinking about email_address because the create path "already handles the new column".

Going deeper

  • Deploy 2's own rollout window — where some replicas are still Stage 1 while others are already Stage 2 — is exactly the gap chapter 7 is about: what a lingering Stage 1 write during this rollout means for the read switch that comes next.
  • The full cross-stage read/write matrix, including this deploy's pair, is proven directly in chapter 8.

← 3. Why migrations run outside the app · Next: 5. The read switch →