Files
asmhatreandClaude Sonnet 5 a875bea55a db-migrations-expand-contract: add a dedicated reproduction for the DDL-collision exception
While writing the article, chapter 14's first failure mode (a concurrent statement
seeing "Table CUSTOMERS not found" while DROP COLUMN runs) was described from the
live load-generator run but had no dedicated, committed reproduction of its own -
CustomerService's own retry would silently absorb it if triggered through the
service layer. DdlCollisionExceptionTest reproduces it directly at the raw JDBC
level, and the chapter and README now link to its captured transcript.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_019Fb7vW8vLyLKngBc4R3huA
2026-09-16 19:33:31 +00:00

214 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 14. The DDL lock window
[← 13. Graceful shutdown vs. kill -9](13-graceful-shutdown-vs-kill-9.md) · [Next: 15. Production checklist →](15-production-checklist.md)
Every schema-only deploy in this sequence — Deploy 1's `ADD COLUMN` and Deploy 4b's
`DROP COLUMN` — runs against a live database while both replicas keep taking real
traffic. That's the entire point of running migrations outside the app (see
[chapter 3](03-why-migrations-run-outside-the-app.md)): zero app restarts for a
schema-only step. What this chapter covers is what "zero app restarts" does *not*
automatically buy you: zero effect on concurrent queries while the `ALTER TABLE`
statement itself is executing.
There are two distinct failure modes here, found the same way — by watching the
article's own load generator run against a live rollout and refusing to wave away
the handful of errors it reported.
## Failure mode 1: a statement that collides with the DDL, and says so
While `V3__drop_email_column.sql` runs, a concurrent, otherwise-correct query can
briefly see the table disappear out from under it.
[`DdlCollisionExceptionTest`](https://ankurm.com/git.app/asmhatre/spring-boot-demo/src/branch/main/db-migrations-expand-contract/src/test/java/com/ankurm/expandcontract/DdlCollisionExceptionTest.java)
reproduces this directly, at the raw JDBC level — one thread reading the table in a
tight loop while `V3` (`DROP COLUMN`) runs concurrently on another connection:
```
successful reads while DROP COLUMN was in flight: 222
reads that collided with the in-flight DROP COLUMN: 1
example: org.h2.jdbc.JdbcSQLSyntaxErrorException: Table "CUSTOMERS" not found; SQL statement:
SELECT COUNT(*) FROM customers [42102-240]
```
Full transcript:
[`docs/output/14-ddl-collision-exception.txt`](https://ankurm.com/git.app/asmhatre/spring-boot-demo/src/branch/main/db-migrations-expand-contract/docs/output/14-ddl-collision-exception.txt).
This test bypasses `CustomerService` deliberately — its own retry would silently
absorb the very exception this test exists to show — and, like
`DdlSilentDataLossTest` below, repeats the race until it reproduces, since exactly
when it fires is OS thread scheduling, not application logic.
This same condition is what first showed up as `create-http-500` / `update-http-500`
errors in this module's own live load-generator run, twice, in independent runs —
not a bug in the application's SQL. H2's TCP server appears to make the table
briefly unavailable to other sessions while `DROP COLUMN` executes. The fix is a
narrowly scoped single retry in
[`CustomerService.withRetryForConcurrentDdl`](https://ankurm.com/git.app/asmhatre/spring-boot-demo/src/branch/main/db-migrations-expand-contract/src/main/java/com/ankurm/expandcontract/customer/CustomerService.java):
```java
private <T> T withRetryForConcurrentDdl(Supplier<T> operation) {
try {
return operation.get();
} catch (BadSqlGrammarException ex) {
if (ex.getMessage() != null && ex.getMessage().contains("CUSTOMERS")) {
try {
Thread.sleep(50);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
}
return operation.get();
}
throw ex;
}
}
```
It catches specifically the "table not found" grammar error, sleeps 50ms — long
enough for the in-flight `ALTER TABLE` to finish — and retries exactly once. It is
deliberately narrow: anything else still propagates. Retrying every `BadSqlGrammarException`
indiscriminately would mask genuine programming errors (a typo'd column name, for
instance) as if they were transient — this retry only fires for the one specific,
verified condition. `create()`, `updateEmail()`, and `findById()` are all wrapped in
it, because a concurrent read can hit the exact same window a concurrent write can.
After adding this retry, a full re-run of the live sequence produced **zero**
`create-http-500` / `update-http-500` errors during Deploy 4b — the class of error
this fix targets is fully eliminated. Compare
[`docs/output/12-load-generator-summary.txt`](https://ankurm.com/git.app/asmhatre/spring-boot-demo/src/branch/main/db-migrations-expand-contract/docs/output/12-load-generator-summary.txt),
where the `04b-contract-migration` phase shows `ok=336 error=0`.
## Failure mode 2: a statement that succeeds, and is still lost
The retry above only helps when the colliding statement *throws*. It does nothing for
the six residual `404`s that remained after the fix — a customer id that a `201`
response had already confirmed existed, later reported not found by a plain read or
update, with no exception anywhere in the logs. That gap between "the fix that
worked" and "the errors that didn't go away" is what led to the actual root cause.
[`DdlSilentDataLossTest`](https://ankurm.com/git.app/asmhatre/spring-boot-demo/src/branch/main/db-migrations-expand-contract/src/test/java/com/ankurm/expandcontract/DdlSilentDataLossTest.java)
reproduces it directly: one thread inserting customers continuously through
`CustomerService.create()`, while `V2` (`ADD COLUMN`) runs concurrently on another
connection.
```java
Thread inserter = new Thread(() -> {
while (!stop.get()) {
long id = stage1.create("Concurrent " + n, "concurrent" + n + "@example.test");
confirmedIds.add(id); // no exception - the insert reported success
}
});
inserter.start();
TestSupport.migrateTo(db, "2"); // ADD COLUMN, running concurrently
```
Exactly when the rebuild's internal scan passes a given row relative to that row's
own commit is OS thread scheduling, not application logic — a single attempt can
land on either side of the race. Rather than assert on one attempt (which failed to
reproduce roughly one run in five while writing this test) or weaken the assertion to
"zero or more" — which would silently stop proving anything the day this stops
reproducing — the test repeats the race on a fresh table until it reproduces, up to
20 times, the same thing a human would do at a terminal to confirm a suspected race
is real:
```
attempts needed to reproduce the race: 2 of 20
customer creates that returned a generated id with no error: 50
customer creates that got the already-documented, already-fixed DDL-collision error: 1
of the ids that came back with no error, missing from the table once V2 finished: 8
example missing ids: [41, 42, 43, 44, 45]
This is why the retry in CustomerService cannot be the whole fix: these inserts
never threw anything to retry. The row was committed, then discarded when the
ADD COLUMN rebuild swapped in a new table that had already been scanned.
```
Full transcript:
[`docs/output/14-ddl-silent-data-loss.txt`](https://ankurm.com/git.app/asmhatre/spring-boot-demo/src/branch/main/db-migrations-expand-contract/docs/output/14-ddl-silent-data-loss.txt).
Under a tight, unthrottled loop with no delay between inserts, this run lost 8 of the
50 rows inserted during the migration window — other runs made while writing this
test lost anywhere from 1 to a few dozen, depending on exactly how the two threads
got scheduled. That number is not a claim about the live article run's own rate — the
load generator's eight threads sleep 1540ms between requests and aren't hammering
the exact millisecond the migration executes, so far fewer of its requests land in
the vulnerable window. It's a claim about the *mechanism*: H2 implements both
`ALTER TABLE ... ADD COLUMN` and
`ALTER TABLE ... DROP COLUMN` by rebuilding the table — copying every row into a new
table with the new column layout and swapping it in. A row inserted on another
connection can commit while that rebuild is mid-scan; depending on exactly when the
scan reaches the row relative to the commit, the row ends up copied into the new
table or left behind in the old one. When it's left behind, it disappears the instant
the rebuild finishes, and the connection that inserted it was never told anything
went wrong — the `INSERT` had already returned successfully.
This is why `withRetryForConcurrentDdl` cannot be "the fix" for the residual errors:
there is nothing to retry. The failure isn't a rejected statement; it's data that
existed for a moment and then didn't, discovered only by a later, unrelated read.
<svg viewBox="0 0 740 220" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="A row committed during an ALTER TABLE rebuild can be silently dropped when the rebuild swaps in the new table">
<style>
text{font-family:-apple-system,Segoe UI,Helvetica,Arial,sans-serif;font-size:12px;fill:#1a1a1a}
.hdr{font-weight:600}
.old{fill:#f4f4f4;stroke:#999}
.new{fill:#eefaf0;stroke:#2f9e52}
.row{fill:#eef4fc;stroke:#3b6fb0}
.lost{fill:#fdeceb;stroke:#c0392b}
.arrow{stroke:#555;stroke-width:1.4;marker-end:url(#a2)}
</style>
<defs><marker id="a2" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6 z" fill="#555"/></marker></defs>
<text x="10" y="20" class="hdr">1. ALTER TABLE begins the rebuild: scans OLD, copies rows into NEW</text>
<rect x="10" y="30" width="180" height="80" class="old"/>
<text x="18" y="48">OLD table</text>
<rect x="18" y="56" width="60" height="16" class="row"/><text x="22" y="68">row 1</text>
<rect x="90" y="56" width="60" height="16" class="row"/><text x="94" y="68">row 2</text>
<line x1="200" y1="70" x2="260" y2="70" class="arrow"/>
<rect x="270" y="30" width="180" height="80" class="new"/>
<text x="278" y="48">NEW table (new columns)</text>
<rect x="278" y="56" width="60" height="16" class="row"/><text x="282" y="68">row 1</text>
<rect x="350" y="56" width="60" height="16" class="row"/><text x="354" y="68">row 2</text>
<text x="10" y="140" class="hdr">2. A concurrent INSERT commits into OLD after the scan already passed that point</text>
<rect x="10" y="150" width="180" height="50" class="old"/>
<rect x="18" y="166" width="60" height="16" class="lost"/><text x="22" y="178">row 3 (new!)</text>
<rect x="270" y="150" width="180" height="50" class="new"/>
<text x="278" y="172">(row 3 never scanned)</text>
<text x="10" y="215" class="hdr">3. Rebuild finishes, NEW swaps in for OLD - row 3 is gone, with no error to anyone</text>
</svg>
The diagram's third step is the whole finding in one line: nothing in this sequence
is a bug in the *application's* SQL, the migration's SQL, or the expand-contract
technique — it's a property of how this specific embedded database implements two
DDL statements that a lot of guidance describes as "safe" without qualification.
## This is a property of H2, not of expand-contract
PostgreSQL's own reference manual is explicit that this isn't universal:
> When a column is added with `ADD COLUMN` and a non-volatile `DEFAULT` is specified
> [or none is], ... In neither case is a rewrite of the table required.
>
> The `DROP COLUMN` form does not physically remove the column, but simply makes it
> invisible to SQL operations.
Both operations are metadata-only in Postgres for the shapes used here — no table
rewrite, and therefore no window where a concurrently committed row can be scanned
past. (Postgres still takes a brief `ACCESS EXCLUSIVE` lock to make the metadata
change, which blocks concurrent statements for that short duration rather than racing
past them — a different, more familiar tradeoff than silent loss.) A real migration
of a real production table should treat this as a question to answer about *your*
database, not assume either behavior: does `ALTER TABLE ADD/DROP COLUMN` rewrite the
table on your engine, and if it does, what does that engine guarantee about
concurrent writes during the rewrite? For MySQL/InnoDB, that answer depends on the
specific `ALGORITHM` the storage engine picks for the given change — `INSTANT` and
`INPLACE` avoid a full rebuild, `COPY` does not.
## Going deeper
- [Chapter 12](12-the-auto-server-trap.md) covers a different H2-specific surprise
found the same way — running as a "shared embedded" database instead of a real
standalone server.
- PostgreSQL's [`ALTER TABLE` reference](https://www.postgresql.org/docs/current/sql-altertable.html) (nofollow)
is the primary source for the Postgres claims above.
- MySQL's [Online DDL documentation](https://dev.mysql.com/doc/refman/8.4/en/innodb-online-ddl.html) (nofollow)
covers which `ALTER TABLE` operations get `INSTANT`/`INPLACE` treatment on InnoDB.
[← 13. Graceful shutdown vs. kill -9](13-graceful-shutdown-vs-kill-9.md) · [Next: 15. Production checklist →](15-production-checklist.md)