Files
spring-boot-demo/db-migrations-flyway-liquibase/docs/11-liquibase-locking.md
T
asmhatreandClaude Sonnet 5 3908331431 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
2026-09-15 07:08:57 +00:00

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)
instance A ≈800ms work instance B polling every ~10s until it happens to check after A released the lock Flyway's row lock releases the waiter the instant it's free (chapter 8: ≈900ms total). Liquibase's default poll rate means the same race can cost up to ~10s of pure waiting.

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 it master.yaml (matching the real changelog's filename) reproduced the phantom success on every run. Naming it anything else — this module settled on bootstrap-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 DirectoryResourceAccessor roots — a completely ordinary application, with exactly one changelog file, will never encounter it.