Files
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

87 lines
5.8 KiB
Markdown

# 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 decompiling
`GlobalConfiguration`'s own bytecode with `javap`, is **10 seconds**:
```
liquibase.changeLogLockPollRate -> default 10 (seconds between checks while the lock is held)
liquibase.changeLogLockWaitTimeInMinutes -> default 5 (minutes before giving up entirely)
```
(from [`docs/output/16-liquibase-lock-defaults-javap.txt`](output/16-liquibase-lock-defaults-javap.txt),
the trimmed `javap -p -c -constants` output showing both `long` constants — `5` and `10` — right next
to the field they initialize)
<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.