Files
spring-boot-demo/db-migrations-flyway-liquibase/docs/08-concurrent-startup-and-locking.md
T
asmhatreandClaude Sonnet 5 67e1b2d8a9 db-migrations-flyway-liquibase: fix dead Redgate/Liquibase doc links and back the lock-defaults claim with a real transcript
Several documentation.red-gate.com and liquibase.com URLs added in the previous commit had
since moved (Redgate restructured its docs under /flyway/reference/, Liquibase Pro pricing
moved to /pricing); this repoints them at the current, verified-200 pages. Also captures
docs/output/16-liquibase-lock-defaults-javap.txt, the trimmed javap output backing chapter
11's claim about Liquibase's default lock-poll and lock-wait settings, which chapter 11
previously asserted without a committed artifact to back it.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Q6XdRjtsp4862EM44T7i9a
2026-09-15 07:31:49 +00:00

58 lines
4.0 KiB
Markdown

# 8. Concurrent startup and locking
[← 7. Why there is no undo](07-why-there-is-no-undo.md) · [Index](../README.md) · Next: [9. Liquibase: anatomy of an update →](09-liquibase-anatomy-of-an-update.md)
A rolling deploy starts several instances of the same application at close to the same moment,
all pointed at the same database, all carrying the same migrations. If two of them both decide
"I need to run V1" at once, what stops the second from either duplicating work or corrupting the
history table?
[`FlywayConcurrentMigrateTest`](../src/test/java/com/ankurm/dbmigrations/flyway/FlywayConcurrentMigrateTest.java)
answers this empirically rather than by reading about it: it starts two Flyway instances on two
real threads, pointed at the same H2 file (opened with `AUTO_SERVER=TRUE` so genuinely separate
JDBC connections can share one file), both migrating a deliberately slow, 800ms Java-based
migration ([`V1__SlowMigration.java`](../src/test/java/com/ankurm/dbmigrations/flyway/V1__SlowMigration.java)).
```
instance A migrate() took 905ms
instance B migrate() took 911ms
wall-clock time for both, run concurrently: 915ms
```
```
flyway_schema_history:
installed_rank | version | description | success
-1 | NULL | ... | true
1 | 1 | SlowMigration| true
```
(from [`docs/output/08-flyway-concurrent-lock.txt`](output/08-flyway-concurrent-lock.txt))
The migration ran exactly once — the test asserts that directly by counting rows matching
`SlowMigration`. What's more interesting is the timing: both instances took **almost the same,
almost-full 800+ms**, and the wall clock for both together is close to *that one duration*, not
their sum. That shape is the real proof of what's happening underneath: Flyway takes a row-level
lock on its own schema history table before checking what needs to run. The winner holds it for
the whole migration; the loser blocks on that same lock for nearly the full window, then — once it
finally acquires it — finds the migration already recorded and returns almost immediately. No
external coordinator, no separate lock table: the schema history table *is* the lock.
<svg viewBox="0 0 700 170" xmlns="http://www.w3.org/2000/svg" font-family="monospace" font-size="13">
<text x="20" y="20">instance A</text>
<rect x="90" y="8" width="260" height="24" fill="#dcfce7" stroke="#16a34a"/>
<text x="220" y="25" text-anchor="middle" font-size="11">holds lock — runs migration (≈800ms)</text>
<text x="20" y="60">instance B</text>
<rect x="90" y="48" width="260" height="24" fill="#fee2e2" stroke="#dc2626"/>
<text x="220" y="65" text-anchor="middle" font-size="11">blocked, waiting for the same row lock</text>
<rect x="352" y="48" width="20" height="24" fill="#dcfce7" stroke="#16a34a"/>
<text x="450" y="65" font-size="11">lock acquired → nothing to do → returns</text>
<text x="20" y="110" font-size="12" fill="#475569">Both calls "took" roughly 800-900ms — the loser's time is almost entirely the wait,</text>
<text x="20" y="128" font-size="12" fill="#475569">not the (nonexistent, for it) work. Wall clock ≈ one migration's length, not the sum of both.</text>
</svg>
## Going deeper
- **A naive assertion here would compare wall-clock time against the sum of both individual durations**, expecting serialization to look like "one after the other, end to end". That's the wrong model for two threads submitted at the same instant with one blocking on the other's lock — the right check is that *both* individual durations are long, proving the loser genuinely waited rather than racing ahead. This module's test was rewritten once to fix exactly that reasoning error.
- [Flyway's locking strategy](https://documentation.red-gate.com/fd/migrations-271585107.html) (`rel="nofollow"`) documents the row-lock approach and which databases support it natively versus via a fallback.
- Liquibase solves the same problem with a dedicated, separate lock table rather than a row lock on the history table itself — chapter [11](11-liquibase-locking.md) measures how differently that behaves under contention.