Add db-migrations-flyway-liquibase: Flyway vs Liquibase migrations, rollbacks and baselines on Spring Boot 4.1
Companion code for the Flyway vs Liquibase article: checksum validation, out-of-order and repeatable migrations, baselining an existing schema, Flyway Community's undo/diff/deploy stubs, concurrent-startup locking for both tools, Liquibase changeset identity and rollback (auto-generated vs explicit), a verified Liquibase 5.0.3 filename-caching defect, the new OSS license service, the FSL license change, and running both tools against one database. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Q6XdRjtsp4862EM44T7i9a
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
# 11. Liquibase locking
|
||||
|
||||
[← 10. Rollback: auto-generated vs explicit](10-rollback-auto-generated-vs-explicit.md) · [Index](../README.md) · Next: [12. The OSS license service →](12-the-oss-license-service.md)
|
||||
|
||||
Liquibase's answer to chapter [8](08-concurrent-startup-and-locking.md) is a dedicated table,
|
||||
`DATABASECHANGELOGLOCK`, holding exactly one row that a real `update()` call has to acquire before
|
||||
touching anything else.
|
||||
[`LiquibaseConcurrentUpdateTest`](../src/test/java/com/ankurm/dbmigrations/liquibase/LiquibaseConcurrentUpdateTest.java)
|
||||
runs the same experiment as the Flyway version: two instances, one deliberately slow (800ms)
|
||||
[`customChange`](../src/test/java/com/ankurm/dbmigrations/liquibase/SlowCustomChange.java), started
|
||||
on two real threads against the same database.
|
||||
|
||||
```
|
||||
instance A update() took 860ms
|
||||
instance B update() took 10094ms
|
||||
wall-clock time for both, run concurrently: 10096ms
|
||||
|
||||
databasechangelog after both finished:
|
||||
ID | AUTHOR | EXECTYPE
|
||||
--------------+--------+---------
|
||||
1-slow-change | ankurm | EXECUTED
|
||||
```
|
||||
|
||||
(from [`docs/output/13-liquibase-lock-contention.txt`](output/13-liquibase-lock-contention.txt))
|
||||
|
||||
The changeset ran exactly once — same guarantee as Flyway. The *shape* of the wait is different,
|
||||
though, and it's a real, verified difference: instance B's call took over ten seconds, for 800ms
|
||||
of underlying work. That's not noise. Liquibase's `LockService` doesn't retry immediately when a
|
||||
lock is held — it polls, and `liquibase.changeLogLockPollRate`'s default, confirmed by reading
|
||||
`GlobalConfiguration`'s own bytecode, is **10 seconds**:
|
||||
|
||||
```
|
||||
liquibase.changeLogLockPollRate → default 10 (seconds between checks while the lock is held)
|
||||
liquibase.changeLogLockWaitTimeInMinutes → default 5 (minutes before giving up entirely)
|
||||
```
|
||||
|
||||
<svg viewBox="0 0 700 150" 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="70" height="24" fill="#dcfce7" stroke="#16a34a"/>
|
||||
<text x="125" y="25" text-anchor="middle" font-size="10">≈800ms work</text>
|
||||
<text x="20" y="60">instance B</text>
|
||||
<rect x="90" y="48" width="560" height="24" fill="#fee2e2" stroke="#dc2626"/>
|
||||
<text x="370" y="65" text-anchor="middle" font-size="11">polling every ~10s until it happens to check after A released the lock</text>
|
||||
<text x="20" y="105" font-size="12" fill="#475569">Flyway's row lock releases the waiter the instant it's free (chapter 8: ≈900ms total).</text>
|
||||
<text x="20" y="123" font-size="12" fill="#475569">Liquibase's default poll rate means the same race can cost up to ~10s of pure waiting.</text>
|
||||
</svg>
|
||||
|
||||
A losing instance in a real rolling deploy can therefore sit doing nothing for up to ten seconds
|
||||
even though the work it's waiting on took under a second — worth knowing before you set a
|
||||
readiness-probe timeout shorter than that.
|
||||
|
||||
## A real defect this test's own history ran into
|
||||
|
||||
An earlier version of this test tried to avoid a *different* race — two instances both trying to
|
||||
**create** `DATABASECHANGELOG`/`DATABASECHANGELOGLOCK` for the first time, which fails with a
|
||||
plain `DatabaseException` ("table already exists") rather than a graceful wait, since the lock
|
||||
table that would make the loser wait doesn't exist yet either — by running a bootstrap `update()`
|
||||
against an *empty* changelog first, to get the tracking tables created before the real race.
|
||||
|
||||
That bootstrap changelog file was originally named `master.yaml`, same as the real one, just in a
|
||||
different directory. Doing that reliably reproduced a genuine Liquibase 5.0.3 defect: both real
|
||||
instances would log a completely normal `Run: 1` / "successful" summary, but the changeset's own
|
||||
code never actually executed, and — checked from each instance's *own* connection, immediately
|
||||
after its own `update()` call, no cross-connection visibility question involved —
|
||||
`DATABASECHANGELOG` stayed empty. A phantom success, caused by something in Liquibase's
|
||||
changelog-history handling that keys off the changelog's simple filename rather than its full
|
||||
resource path.
|
||||
|
||||
<blockquote style="border-left:4px solid #dc2626;background:#fef2f2;padding:12px 16px;margin:16px 0;">
|
||||
<strong>Verified, not guessed:</strong> this was isolated by toggling <em>only</em> the bootstrap
|
||||
file's name with everything else held constant. Naming it <code>master.yaml</code> (matching the
|
||||
real changelog's filename) reproduced the phantom success on every run. Naming it anything else —
|
||||
this module settled on <code>bootstrap-only.yaml</code> — never did, across dozens of runs. See
|
||||
<a href="../src/test/java/com/ankurm/dbmigrations/liquibase/LiquibaseConcurrentUpdateTest.java">the
|
||||
test's own javadoc</a> for the exact reproduction notes.
|
||||
</blockquote>
|
||||
|
||||
## Going deeper
|
||||
|
||||
- [`GlobalConfiguration`](https://javadoc.io/doc/org.liquibase/liquibase-core/latest/liquibase/GlobalConfiguration.html) (`rel="nofollow"`) lists every global Liquibase setting, including both lock-related ones above, with their system-property and environment-variable spellings.
|
||||
- If you hit a "table already exists" failure the very first time two instances of a brand-new service start against a brand-new database, this is why — it's a one-time bootstrap race, not a recurring lock-contention bug, and a health-check retry (the same thing a rolling deploy already does for a failed pod) resolves it.
|
||||
- This filename-collision defect is specific to how this test constructed two changelogs with the same simple name from two different `DirectoryResourceAccessor` roots — a completely ordinary application, with exactly one changelog file, will never encounter it.
|
||||
Reference in New Issue
Block a user