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:
2026-09-15 07:08:57 +00:00
co-authored by Claude Sonnet 5
parent b02fbe1416
commit 3908331431
61 changed files with 3128 additions and 0 deletions
@@ -0,0 +1,121 @@
package com.ankurm.dbmigrations.liquibase;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.DriverManager;
import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import liquibase.Contexts;
import liquibase.Liquibase;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import com.ankurm.dbmigrations.DbDump;
import com.ankurm.dbmigrations.Transcript;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Liquibase's answer to {@code FlywayConcurrentMigrateTest}: a single-row lock table,
* {@code DATABASECHANGELOGLOCK}, that a real second {@code update()} call has to wait for.
* <p>
* Two Liquibase instances racing to create {@code DATABASECHANGELOG}/{@code DATABASECHANGELOGLOCK}
* for the very first time is a real, separate failure mode from the lock contention this test is
* about — the loser gets a plain {@code DatabaseException} ("table already exists"), because the
* lock table that would make it wait gracefully doesn't exist yet either. So a bootstrap step runs
* first, on its own {@link Liquibase} instance, against an empty changelog, purely to get those two
* tracking tables created before the real race starts.
* <p>
* That bootstrap changelog file is deliberately named {@code bootstrap-only.yaml}, not
* {@code master.yaml} — reusing the same filename for the bootstrap and the real changelog (even
* from a different directory, via a different {@code DirectoryResourceAccessor}) reliably
* reproduced a genuine Liquibase 5.0.3 defect: both real instances would log a normal
* {@code Run: 1} / "successful" summary, but the changeset's own code never ran and
* {@code DATABASECHANGELOG} stayed empty — a phantom success caused by something in Liquibase's
* changelog-history handling keying off the changelog's simple filename rather than the full
* resource path. Verified by toggling only the bootstrap file's name with everything else held
* constant: same name reproduces it every time, a distinct name never does. See
* docs/11-liquibase-locking.md.
*/
class LiquibaseConcurrentUpdateTest {
@Test
void twoInstancesUpdatingAtOnceAreSerializedNotDuplicated(@TempDir Path tmp) throws Exception {
Path changelogDir = tmp.resolve("changelog");
Files.createDirectories(changelogDir);
Files.writeString(changelogDir.resolve("master.yaml"), """
databaseChangeLog:
- changeSet:
id: 1-slow-change
author: ankurm
changes:
- customChange:
class: com.ankurm.dbmigrations.liquibase.SlowCustomChange
""");
Path bootstrapChangelogDir = tmp.resolve("bootstrap-changelog");
Files.createDirectories(bootstrapChangelogDir);
Files.writeString(bootstrapChangelogDir.resolve("bootstrap-only.yaml"), "databaseChangeLog: []\n");
String url = "jdbc:h2:file:" + tmp.resolve("liquibase-concurrent") + ";AUTO_SERVER=TRUE";
// Bootstrap DATABASECHANGELOG / DATABASECHANGELOGLOCK first, on a changelog file with a
// name distinct from the real one (see the class javadoc for why that distinction matters).
try (Liquibase bootstrap = LiquibaseTestSupport.open(url, bootstrapChangelogDir, "bootstrap-only.yaml")) {
bootstrap.update(new Contexts());
}
ExecutorService pool = Executors.newFixedThreadPool(2);
Instant start = Instant.now();
try {
Future<Long> instanceA = pool.submit(() -> runUpdate(url, changelogDir));
Future<Long> instanceB = pool.submit(() -> runUpdate(url, changelogDir));
long millisA = instanceA.get();
long millisB = instanceB.get();
Duration total = Duration.between(start, Instant.now());
Transcript t = Transcript.start("13-liquibase-lock-contention",
"Liquibase: two instances calling update() at the same moment");
t.line("instance A update() took " + millisA + "ms");
t.line("instance B update() took " + millisB + "ms");
t.line("wall-clock time for both, run concurrently: " + total.toMillis() + "ms");
try (Connection conn = DriverManager.getConnection(url, "sa", "")) {
t.section("databasechangeloglock after both finished");
t.line(DbDump.table(conn, "select id, locked, lockedby from databasechangeloglock"));
t.section("databasechangelog after both finished");
String log = DbDump.table(conn, "select id, author, exectype from databasechangelog");
t.line(log);
assertThat(log.lines().filter(l -> l.contains("1-slow-change")).count())
.as("the slow changeset must run exactly once despite two concurrent update() calls")
.isEqualTo(1);
}
t.write();
// Exactly one of the two calls does the real 800ms of work; the other blocks on
// DATABASECHANGELOGLOCK for roughly that same window before finding nothing left to
// do. That means the wall clock for both together is close to ONE migration's
// duration, not their sum — so the direct proof of serialization is that whichever
// call "lost" the race still took nearly as long as the winner, instead of returning
// near-instantly the way an unlocked, do-nothing update() call would.
assertThat(millisA).isGreaterThan(600);
assertThat(millisB).isGreaterThan(600);
}
finally {
pool.shutdown();
}
}
private static long runUpdate(String url, Path changelogDir) throws Exception {
Instant t0 = Instant.now();
try (Liquibase liquibase = LiquibaseTestSupport.open(url, changelogDir, "master.yaml")) {
liquibase.update(new Contexts());
}
return Duration.between(t0, Instant.now()).toMillis();
}
}
@@ -0,0 +1,68 @@
package com.ankurm.dbmigrations.liquibase;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.DriverManager;
import liquibase.Liquibase;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import com.ankurm.dbmigrations.DbDump;
import com.ankurm.dbmigrations.Transcript;
import static org.assertj.core.api.Assertions.assertThat;
/**
* The Liquibase equivalent of {@code FlywayHappyPathTest}: two changesets, applied once. See
* docs/09-liquibase-anatomy-of-an-update.md.
*/
class LiquibaseHappyPathTest {
@Test
void appliesChangeSetsInOrder(@TempDir Path tmp) throws Exception {
Path changelogDir = tmp.resolve("changelog");
Files.createDirectories(changelogDir);
Files.writeString(changelogDir.resolve("master.yaml"), """
databaseChangeLog:
- changeSet:
id: 1-create-account
author: ankurm
changes:
- createTable:
tableName: account
columns:
- column: {name: id, type: bigint, autoIncrement: true, constraints: {primaryKey: true}}
- column: {name: owner, type: varchar(100), constraints: {nullable: false}}
- changeSet:
id: 2-seed-account
author: ankurm
changes:
- insert:
tableName: account
columns:
- {column: {name: owner, value: 'Katherine Johnson'}}
""");
String url = "jdbc:h2:file:" + tmp.resolve("liquibase-happy-path");
Transcript t = Transcript.start("09-liquibase-happy-path", "Liquibase: two changesets, applied on startup");
Liquibase liquibase = LiquibaseTestSupport.open(url, changelogDir, "master.yaml");
liquibase.update();
try (Connection conn = DriverManager.getConnection(url, "sa", "")) {
t.section("databasechangelog");
String log = DbDump.table(conn,
"select id, author, filename, orderexecuted, exectype from databasechangelog order by orderexecuted");
t.line(log);
assertThat(log).contains("1-create-account").contains("2-seed-account");
t.section("account table");
String rows = DbDump.table(conn, "select id, owner from account");
t.line(rows);
assertThat(rows).contains("Katherine Johnson");
}
t.write();
}
}
@@ -0,0 +1,35 @@
package com.ankurm.dbmigrations.liquibase;
import liquibase.Scope;
import liquibase.license.LicenseServiceFactory;
import org.junit.jupiter.api.Test;
import com.ankurm.dbmigrations.Transcript;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Liquibase Community 5.0 shipped a {@code liquibase.license} package inside {@code liquibase-core}
* itself — new since the Functional Source License change (docs/13-the-fsl-license-change.md).
* This test asks the actual service Spring Boot's {@code LiquibaseAutoConfiguration} runs on top
* of whether it is licensed, rather than repeating what the release notes say. See
* docs/12-the-oss-license-service.md.
*/
class LiquibaseLicenseServiceTest {
@Test
void theOssServiceReportsNoLicenseAndDoesNotFail() {
var service = Scope.getCurrentScope().getSingleton(LicenseServiceFactory.class).getLicenseService();
Transcript t = Transcript.start("14-liquibase-oss-license-service",
"Liquibase: the LicenseService actually wired up on the classpath Boot uses");
t.line("implementation: " + service.getClass().getName());
t.line("licenseIsInstalled(): " + service.licenseIsInstalled());
t.line("licenseIsValid(\"any\"): " + service.licenseIsValid("any"));
t.line("getLicenseInfo(): \"" + service.getLicenseInfo() + "\"");
t.write();
assertThat(service.getClass().getSimpleName()).isEqualTo("OSSLicenseService");
assertThat(service.licenseIsInstalled()).isFalse();
}
}
@@ -0,0 +1,73 @@
package com.ankurm.dbmigrations.liquibase;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import liquibase.Contexts;
import liquibase.LabelExpression;
import liquibase.Liquibase;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import com.ankurm.dbmigrations.DbDump;
import com.ankurm.dbmigrations.Transcript;
import static org.assertj.core.api.Assertions.assertThat;
/**
* {@code createTable} is one of the change types Liquibase can invert on its own — it just runs
* {@code DROP TABLE}. No {@code <rollback>} block was written anywhere in this changelog.
* See docs/10-rollback-auto-generated-vs-explicit.md.
*/
class LiquibaseRollbackAutoTest {
@Test
void rollingBackACreateTableChangeSetNeedsNoExplicitRollback(@TempDir Path tmp) throws Exception {
Path changelogDir = tmp.resolve("changelog");
Files.createDirectories(changelogDir);
Files.writeString(changelogDir.resolve("master.yaml"), """
databaseChangeLog:
- changeSet:
id: 1-create-session
author: ankurm
changes:
- createTable:
tableName: session
columns:
- column: {name: id, type: bigint, autoIncrement: true, constraints: {primaryKey: true}}
- column: {name: token, type: varchar(64)}
""");
String url = "jdbc:h2:file:" + tmp.resolve("rollback-auto");
Transcript t = Transcript.start("10-liquibase-rollback-auto", "Liquibase: auto-generated rollback for createTable");
try (Liquibase liquibase = LiquibaseTestSupport.open(url, changelogDir, "master.yaml")) {
liquibase.update();
}
t.line("after update(): session table exists = " + tableExists(url, "SESSION"));
try (Liquibase liquibase = LiquibaseTestSupport.open(url, changelogDir, "master.yaml")) {
liquibase.rollback(1, new Contexts(), new LabelExpression());
}
boolean existsAfterRollback = tableExists(url, "SESSION");
t.line("after rollback(1): session table exists = " + existsAfterRollback);
try (Connection conn = DriverManager.getConnection(url, "sa", "")) {
t.section("databasechangelog after rollback");
t.line(DbDump.table(conn, "select id, exectype from databasechangelog"));
}
t.write();
assertThat(existsAfterRollback).isFalse();
}
private static boolean tableExists(String url, String tableName) throws SQLException {
try (Connection conn = DriverManager.getConnection(url, "sa", "");
var rs = conn.getMetaData().getTables(null, "PUBLIC", tableName, null)) {
return rs.next();
}
}
}
@@ -0,0 +1,77 @@
package com.ankurm.dbmigrations.liquibase;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.DriverManager;
import liquibase.Contexts;
import liquibase.LabelExpression;
import liquibase.Liquibase;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import com.ankurm.dbmigrations.DbDump;
import com.ankurm.dbmigrations.Transcript;
import static org.assertj.core.api.Assertions.assertThat;
/**
* The same {@code insert} changeset as {@link LiquibaseRollbackFailTest}, this time with an
* explicit {@code rollback:} block — the fix for that failure, not just a description of it.
* See docs/10-rollback-auto-generated-vs-explicit.md.
*/
class LiquibaseRollbackExplicitTest {
@Test
void anExplicitRollbackBlockMakesTheSameInsertReversible(@TempDir Path tmp) throws Exception {
Path changelogDir = tmp.resolve("changelog");
Files.createDirectories(changelogDir);
Files.writeString(changelogDir.resolve("master.yaml"), """
databaseChangeLog:
- changeSet:
id: 1-create-audit-log
author: ankurm
changes:
- createTable:
tableName: audit_log
columns:
- column: {name: id, type: bigint, autoIncrement: true, constraints: {primaryKey: true}}
- column: {name: event, type: varchar(100)}
- changeSet:
id: 2-seed-audit-log
author: ankurm
changes:
- insert:
tableName: audit_log
columns:
- {column: {name: event, value: 'system-start'}}
rollback:
- delete:
tableName: audit_log
where: event='system-start'
""");
String url = "jdbc:h2:file:" + tmp.resolve("rollback-explicit");
Transcript t = Transcript.start("12-liquibase-rollback-explicit",
"Liquibase: the same insert, now with an explicit rollback block");
Liquibase liquibase = LiquibaseTestSupport.open(url, changelogDir, "master.yaml");
liquibase.update();
try (Connection conn = DriverManager.getConnection(url, "sa", "")) {
t.section("audit_log right after update()");
t.line(DbDump.table(conn, "select id, event from audit_log"));
}
Liquibase forRollback = LiquibaseTestSupport.open(url, changelogDir, "master.yaml");
forRollback.rollback(1, new Contexts(), new LabelExpression());
try (Connection conn = DriverManager.getConnection(url, "sa", "")) {
t.section("audit_log after rollback(1) — table still exists, the row is gone");
String rows = DbDump.table(conn, "select id, event from audit_log");
t.line(rows);
assertThat(rows).contains("(0 rows)");
}
t.write();
}
}
@@ -0,0 +1,75 @@
package com.ankurm.dbmigrations.liquibase;
import java.nio.file.Files;
import java.nio.file.Path;
import liquibase.Contexts;
import liquibase.LabelExpression;
import liquibase.Liquibase;
import liquibase.exception.LiquibaseException;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import com.ankurm.dbmigrations.Transcript;
import static org.assertj.core.api.Assertions.assertThat;
/**
* {@code insert} is not in the list of change types Liquibase can invert automatically. With no
* {@code <rollback>} block, rolling it back fails — for real, not as a documented limitation
* taken on faith. See docs/10-rollback-auto-generated-vs-explicit.md.
*/
class LiquibaseRollbackFailTest {
@Test
void rollingBackAnInsertWithNoExplicitRollbackFails(@TempDir Path tmp) throws Exception {
Path changelogDir = tmp.resolve("changelog");
Files.createDirectories(changelogDir);
Files.writeString(changelogDir.resolve("master.yaml"), """
databaseChangeLog:
- changeSet:
id: 1-create-audit-log
author: ankurm
changes:
- createTable:
tableName: audit_log
columns:
- column: {name: id, type: bigint, autoIncrement: true, constraints: {primaryKey: true}}
- column: {name: event, type: varchar(100)}
- changeSet:
id: 2-seed-audit-log
author: ankurm
changes:
- insert:
tableName: audit_log
columns:
- {column: {name: event, value: 'system-start'}}
""");
String url = "jdbc:h2:file:" + tmp.resolve("rollback-fail");
Transcript t = Transcript.start("11-liquibase-rollback-no-inverse",
"Liquibase: rolling back an insert changeset with no <rollback> block");
Liquibase liquibase = LiquibaseTestSupport.open(url, changelogDir, "master.yaml");
liquibase.update();
t.line("after update(): both changesets applied");
Liquibase forRollback = LiquibaseTestSupport.open(url, changelogDir, "master.yaml");
LiquibaseException failure = catchRollbackFailure(forRollback);
t.line("rollback(1) threw: " + failure.getClass().getName());
t.line("message: " + failure.getMessage());
t.write();
assertThat(failure.getMessage()).containsIgnoringCase("no inverse");
}
private static LiquibaseException catchRollbackFailure(Liquibase liquibase) {
try {
liquibase.rollback(1, new Contexts(), new LabelExpression());
}
catch (LiquibaseException ex) {
return ex;
}
throw new AssertionError("expected rollback(1) to fail for a changeset with no explicit rollback");
}
}
@@ -0,0 +1,24 @@
package com.ankurm.dbmigrations.liquibase;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.DriverManager;
import liquibase.Liquibase;
import liquibase.database.Database;
import liquibase.database.DatabaseFactory;
import liquibase.database.jvm.JdbcConnection;
import liquibase.resource.DirectoryResourceAccessor;
/** Wires up the classic {@link liquibase.Liquibase} facade against a filesystem changelog — no Spring involved. */
final class LiquibaseTestSupport {
private LiquibaseTestSupport() {
}
static Liquibase open(String jdbcUrl, Path changelogDir, String changelogFile) throws Exception {
Connection conn = DriverManager.getConnection(jdbcUrl, "sa", "");
Database database = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(new JdbcConnection(conn));
return new Liquibase(changelogFile, new DirectoryResourceAccessor(changelogDir), database);
}
}
@@ -0,0 +1,48 @@
package com.ankurm.dbmigrations.liquibase;
import liquibase.change.custom.CustomTaskChange;
import liquibase.database.Database;
import liquibase.database.jvm.JdbcConnection;
import liquibase.exception.CustomChangeException;
import liquibase.exception.SetupException;
import liquibase.exception.ValidationErrors;
import liquibase.resource.ResourceAccessor;
/**
* A deliberately slow {@code customChange}, used only by {@link LiquibaseConcurrentUpdateTest} to
* widen the window in which a second {@code update()} can try to run against the same database.
*/
public class SlowCustomChange implements CustomTaskChange {
@Override
public void execute(Database database) throws CustomChangeException {
try {
JdbcConnection conn = (JdbcConnection) database.getConnection();
try (var st = conn.createStatement()) {
st.execute("create table slow_changeset_marker (id int primary key)");
}
Thread.sleep(800);
}
catch (Exception ex) {
throw new CustomChangeException(ex);
}
}
@Override
public String getConfirmationMessage() {
return "slow custom change applied";
}
@Override
public void setUp() throws SetupException {
}
@Override
public void setFileOpener(ResourceAccessor resourceAccessor) {
}
@Override
public ValidationErrors validate(Database database) {
return new ValidationErrors();
}
}