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
5.5 KiB
11. Liquibase locking
← 10. Rollback: auto-generated vs explicit · Index · Next: 12. The OSS license service →
Liquibase's answer to chapter 8 is a dedicated table,
DATABASECHANGELOGLOCK, holding exactly one row that a real update() call has to acquire before
touching anything else.
LiquibaseConcurrentUpdateTest
runs the same experiment as the Flyway version: two instances, one deliberately slow (800ms)
customChange, 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)
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)
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.
Verified, not guessed: this was isolated by toggling only the bootstrap file's name with everything else held constant. Naming itmaster.yaml(matching the real changelog's filename) reproduced the phantom success on every run. Naming it anything else — this module settled onbootstrap-only.yaml— never did, across dozens of runs. See the test's own javadoc for the exact reproduction notes.
Going deeper
GlobalConfiguration(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
DirectoryResourceAccessorroots — a completely ordinary application, with exactly one changelog file, will never encounter it.