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:
+99
@@ -0,0 +1,99 @@
|
||||
package com.ankurm.dbmigrations;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.sql.Connection;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||
import org.springframework.boot.flyway.autoconfigure.FlywayAutoConfiguration;
|
||||
import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration;
|
||||
import org.springframework.boot.liquibase.autoconfigure.LiquibaseAutoConfiguration;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Both starters, both enabled, one database — the question every "which one do I add" thread
|
||||
* on Stack Overflow eventually asks in a comment. The naive answer is no: Boot wires Liquibase's
|
||||
* {@code SpringLiquibase} bean before Flyway's {@code FlywayMigrationInitializer} in this
|
||||
* configuration (an artifact of bean registration order here, not a documented contract), so by
|
||||
* the time Flyway runs, Liquibase has already created tables, and Flyway's own safety check —
|
||||
* "I found tables I don't recognize and no schema history table" — refuses to start. Turning on
|
||||
* {@code baselineOnMigrate} alone is not enough here: its default {@code baselineVersion} (1)
|
||||
* tells Flyway "pretend V1 already ran", which only makes sense if the existing schema really
|
||||
* matches what V1 would have built. Liquibase's changelog built an unrelated {@code product}
|
||||
* table, not {@code customer}, so V1 also has to be told to baseline-version 0 (nothing has run
|
||||
* yet, from Flyway's point of view) so it runs its own migration set from scratch alongside
|
||||
* whatever Liquibase already put there. See docs/14-running-both-at-once.md.
|
||||
*/
|
||||
class BothTogetherTest {
|
||||
|
||||
@Test
|
||||
void enablingBothNaivelyFailsUntilFlywayIsToldToBaseline(@TempDir Path tmp) throws Exception {
|
||||
Transcript t = Transcript.start("15-both-together-same-datasource",
|
||||
"Flyway and Liquibase, both enabled: the naive failure, then the fix");
|
||||
|
||||
Path naiveDb = tmp.resolve("both-together-naive");
|
||||
t.section("naive: both enabled, no other configuration");
|
||||
new ApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of(DataSourceAutoConfiguration.class,
|
||||
FlywayAutoConfiguration.class, LiquibaseAutoConfiguration.class))
|
||||
.withPropertyValues(
|
||||
"spring.datasource.url=jdbc:h2:file:" + naiveDb,
|
||||
"spring.datasource.username=sa",
|
||||
"spring.flyway.locations=classpath:db/migration",
|
||||
"spring.liquibase.change-log=classpath:db/changelog/db.changelog-master.yaml")
|
||||
.run(ctx -> {
|
||||
assertThat(ctx).hasFailed();
|
||||
String message = rootMessage(ctx.getStartupFailure());
|
||||
t.line("context failed to start: " + message);
|
||||
assertThat(message).containsIgnoringCase("non-empty").containsIgnoringCase("baseline");
|
||||
});
|
||||
|
||||
Path fixedDb = tmp.resolve("both-together-fixed");
|
||||
t.section("fixed: spring.flyway.baseline-on-migrate=true, spring.flyway.baseline-version=0");
|
||||
new ApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of(DataSourceAutoConfiguration.class,
|
||||
FlywayAutoConfiguration.class, LiquibaseAutoConfiguration.class))
|
||||
.withPropertyValues(
|
||||
"spring.datasource.url=jdbc:h2:file:" + fixedDb,
|
||||
"spring.datasource.username=sa",
|
||||
"spring.flyway.locations=classpath:db/migration",
|
||||
"spring.flyway.baseline-on-migrate=true",
|
||||
"spring.flyway.baseline-version=0",
|
||||
"spring.liquibase.change-log=classpath:db/changelog/db.changelog-master.yaml")
|
||||
.run(ctx -> {
|
||||
assertThat(ctx).hasNotFailed();
|
||||
DataSource ds = ctx.getBean(DataSource.class);
|
||||
try (Connection conn = ds.getConnection()) {
|
||||
t.line(DbDump.table(conn,
|
||||
"select table_name from information_schema.tables where table_schema='PUBLIC' order by table_name"));
|
||||
t.section("flyway_schema_history — baseline row at 0, then V1/V2 ran for real");
|
||||
String flywayHistory = DbDump.table(conn,
|
||||
"select \"version\", \"description\", \"type\", \"success\" from \"flyway_schema_history\" order by \"installed_rank\"");
|
||||
t.line(flywayHistory);
|
||||
t.section("databasechangelog — Liquibase's own bookkeeping, untouched by Flyway");
|
||||
t.line(DbDump.table(conn, "select id, author, exectype from databasechangelog order by orderexecuted"));
|
||||
|
||||
String tables = DbDump.table(conn,
|
||||
"select table_name from information_schema.tables where table_schema='PUBLIC' order by table_name");
|
||||
assertThat(tables).contains("CUSTOMER").contains("PRODUCT").contains("DATABASECHANGELOG");
|
||||
assertThat(flywayHistory).contains("create customer").contains("seed customer");
|
||||
}
|
||||
});
|
||||
|
||||
t.write();
|
||||
}
|
||||
|
||||
private static String rootMessage(Throwable t) {
|
||||
Throwable cause = t;
|
||||
while (cause.getCause() != null) {
|
||||
cause = cause.getCause();
|
||||
}
|
||||
return cause.getClass().getSimpleName() + ": " + cause.getMessage();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.ankurm.dbmigrations;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.ResultSetMetaData;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** Renders a JDBC query as a plain-text table for transcripts — no ORM, no formatting library. */
|
||||
public final class DbDump {
|
||||
|
||||
private DbDump() {
|
||||
}
|
||||
|
||||
public static String table(Connection conn, String sql) {
|
||||
try (Statement st = conn.createStatement(); ResultSet rs = st.executeQuery(sql)) {
|
||||
ResultSetMetaData meta = rs.getMetaData();
|
||||
int cols = meta.getColumnCount();
|
||||
List<String> headers = new ArrayList<>();
|
||||
for (int i = 1; i <= cols; i++) {
|
||||
headers.add(meta.getColumnLabel(i));
|
||||
}
|
||||
List<List<String>> rows = new ArrayList<>();
|
||||
while (rs.next()) {
|
||||
List<String> row = new ArrayList<>();
|
||||
for (int i = 1; i <= cols; i++) {
|
||||
Object v = rs.getObject(i);
|
||||
row.add(v == null ? "NULL" : v.toString());
|
||||
}
|
||||
rows.add(row);
|
||||
}
|
||||
int[] widths = new int[cols];
|
||||
for (int i = 0; i < cols; i++) {
|
||||
widths[i] = headers.get(i).length();
|
||||
}
|
||||
for (List<String> row : rows) {
|
||||
for (int i = 0; i < cols; i++) {
|
||||
widths[i] = Math.max(widths[i], row.get(i).length());
|
||||
}
|
||||
}
|
||||
StringBuilder sb = new StringBuilder();
|
||||
appendRow(sb, headers, widths);
|
||||
StringBuilder sep = new StringBuilder();
|
||||
for (int i = 0; i < cols; i++) {
|
||||
sep.append("-".repeat(widths[i])).append(i < cols - 1 ? "-+-" : "");
|
||||
}
|
||||
sb.append(sep).append(System.lineSeparator());
|
||||
for (List<String> row : rows) {
|
||||
appendRow(sb, row, widths);
|
||||
}
|
||||
sb.append("(").append(rows.size()).append(" row").append(rows.size() == 1 ? "" : "s").append(")");
|
||||
return sb.toString();
|
||||
}
|
||||
catch (SQLException ex) {
|
||||
return "query failed: " + ex.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
private static void appendRow(StringBuilder sb, List<String> values, int[] widths) {
|
||||
for (int i = 0; i < values.size(); i++) {
|
||||
sb.append(pad(values.get(i), widths[i]));
|
||||
sb.append(i < values.size() - 1 ? " | " : "");
|
||||
}
|
||||
sb.append(System.lineSeparator());
|
||||
}
|
||||
|
||||
private static String pad(String s, int width) {
|
||||
return s + " ".repeat(Math.max(0, width - s.length()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.ankurm.dbmigrations;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* Writes a named transcript under {@code docs/output/} while the test that produced it runs.
|
||||
* Every console-line quoted in the companion article for this module comes from one of these
|
||||
* files, and the file is only ever written by a test assertion — never hand-typed.
|
||||
*
|
||||
* See docs/15-production-checklist.md for how this module's real output is regenerated.
|
||||
*/
|
||||
public final class Transcript {
|
||||
|
||||
private static final Path OUTPUT_DIR = Paths.get("docs", "output");
|
||||
|
||||
private final StringBuilder buffer = new StringBuilder();
|
||||
private final String name;
|
||||
|
||||
private Transcript(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public static Transcript start(String name, String heading) {
|
||||
Transcript t = new Transcript(name);
|
||||
t.line("=".repeat(Math.min(78, heading.length() + 4)));
|
||||
t.line(heading);
|
||||
t.line("=".repeat(Math.min(78, heading.length() + 4)));
|
||||
t.line("captured: " + Instant.now());
|
||||
t.line("");
|
||||
return t;
|
||||
}
|
||||
|
||||
public Transcript line(String text) {
|
||||
buffer.append(text).append(System.lineSeparator());
|
||||
System.out.println(text);
|
||||
return this;
|
||||
}
|
||||
|
||||
public Transcript section(String title) {
|
||||
line("");
|
||||
line("-- " + title + " --");
|
||||
return this;
|
||||
}
|
||||
|
||||
public void write() {
|
||||
try {
|
||||
Files.createDirectories(OUTPUT_DIR);
|
||||
Path target = OUTPUT_DIR.resolve(name + ".txt");
|
||||
Files.writeString(target, buffer.toString(), StandardOpenOption.CREATE,
|
||||
StandardOpenOption.TRUNCATE_EXISTING);
|
||||
}
|
||||
catch (IOException ex) {
|
||||
throw new UncheckedIOException(ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
package com.ankurm.dbmigrations.flyway;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.Statement;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||
import org.springframework.boot.flyway.autoconfigure.FlywayAutoConfiguration;
|
||||
import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
|
||||
import com.ankurm.dbmigrations.DbDump;
|
||||
import com.ankurm.dbmigrations.Transcript;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Adopting Flyway into a database that already has a schema — the case every "add Flyway to our
|
||||
* five-year-old app" migration hits on day one. See docs/06-baselining-an-existing-database.md.
|
||||
*/
|
||||
class FlywayBaselineTest {
|
||||
|
||||
@Test
|
||||
void adoptingFlywayAgainstAnExistingSchemaNeedsABaseline(@TempDir Path tmp) throws Exception {
|
||||
Path db = tmp.resolve("baseline-demo");
|
||||
String url = "jdbc:h2:file:" + db;
|
||||
|
||||
// Simulate five years of hand-run DDL: the table already exists, with no Flyway involved.
|
||||
try (Connection conn = DriverManager.getConnection(url, "sa", ""); Statement st = conn.createStatement()) {
|
||||
st.execute("create table legacy_account (id bigint primary key, owner varchar(100))");
|
||||
st.execute("insert into legacy_account values (1, 'pre-flyway-data')");
|
||||
}
|
||||
|
||||
Path migrations = tmp.resolve("migrations");
|
||||
Files.createDirectories(migrations);
|
||||
// V1 describes the schema that ALREADY exists — this is what makes it a baseline candidate.
|
||||
Files.writeString(migrations.resolve("V1__init.sql"),
|
||||
"create table legacy_account (id bigint primary key, owner varchar(100));\n");
|
||||
Files.writeString(migrations.resolve("V2__add_status_column.sql"),
|
||||
"alter table legacy_account add column status varchar(20) default 'ACTIVE';\n");
|
||||
|
||||
Transcript t = Transcript.start("05-flyway-baseline", "Flyway: adopting an existing, unmanaged schema");
|
||||
|
||||
t.section("migrate() with no baseline configuration, against a non-empty schema");
|
||||
new ApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of(DataSourceAutoConfiguration.class, FlywayAutoConfiguration.class))
|
||||
.withPropertyValues(
|
||||
"spring.datasource.url=" + url,
|
||||
"spring.datasource.username=sa",
|
||||
"spring.flyway.locations=filesystem:" + migrations)
|
||||
.run(ctx -> {
|
||||
assertThat(ctx).hasFailed();
|
||||
String message = rootMessage(ctx.getStartupFailure());
|
||||
t.line(message);
|
||||
assertThat(message).containsIgnoringCase("non-empty").containsIgnoringCase("baseline");
|
||||
});
|
||||
|
||||
t.section("migrate() with baselineOnMigrate=true, baselineVersion=1");
|
||||
new ApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of(DataSourceAutoConfiguration.class, FlywayAutoConfiguration.class))
|
||||
.withPropertyValues(
|
||||
"spring.datasource.url=" + url,
|
||||
"spring.datasource.username=sa",
|
||||
"spring.flyway.locations=filesystem:" + migrations,
|
||||
"spring.flyway.baseline-on-migrate=true",
|
||||
"spring.flyway.baseline-version=1",
|
||||
"spring.flyway.baseline-description=pre-flyway schema")
|
||||
.run(ctx -> {
|
||||
assertThat(ctx).hasNotFailed();
|
||||
DataSource ds = ctx.getBean(DataSource.class);
|
||||
try (Connection conn = ds.getConnection()) {
|
||||
String history = DbDump.table(conn,
|
||||
"select \"installed_rank\", \"version\", \"description\", \"type\", \"success\" from \"flyway_schema_history\" order by \"installed_rank\"");
|
||||
t.line(history);
|
||||
// V1's own SQL never runs a second time — it would fail, the table already exists.
|
||||
// The baseline row's description is whatever baseline-description was set to, and its
|
||||
// type is BASELINE, not a fixed "<< Flyway Baseline >>" marker (that text is only the
|
||||
// default when no baseline-description is given).
|
||||
assertThat(history).contains("BASELINE").contains("pre-flyway schema").contains("add status column");
|
||||
|
||||
t.section("legacy_account keeps its pre-existing row and gains the new column");
|
||||
t.line(DbDump.table(conn, "select id, owner, status from legacy_account"));
|
||||
}
|
||||
});
|
||||
|
||||
t.write();
|
||||
}
|
||||
|
||||
private static String rootMessage(Throwable t) {
|
||||
Throwable cause = t;
|
||||
while (cause.getCause() != null) {
|
||||
cause = cause.getCause();
|
||||
}
|
||||
return cause.getClass().getSimpleName() + ": " + cause.getMessage();
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package com.ankurm.dbmigrations.flyway;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||
import org.springframework.boot.flyway.autoconfigure.FlywayAutoConfiguration;
|
||||
import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
|
||||
import com.ankurm.dbmigrations.Transcript;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Edits an already-applied migration file and restarts the context against the same database.
|
||||
* Flyway validates checksums before migrating by default ({@code validateOnMigrate=true}), so
|
||||
* the second startup fails — this is the real exception message, not a paraphrase.
|
||||
* See docs/03-checksum-validation.md.
|
||||
*/
|
||||
class FlywayChecksumMismatchTest {
|
||||
|
||||
@Test
|
||||
void editingAnAppliedMigrationFailsValidation(@TempDir Path tmp) throws Exception {
|
||||
Path db = tmp.resolve("checksum-demo");
|
||||
Path migrations = tmp.resolve("migrations");
|
||||
Files.createDirectories(migrations);
|
||||
Path v1 = migrations.resolve("V1__init.sql");
|
||||
Files.writeString(v1, "create table widget (id bigint primary key, name varchar(50));\n");
|
||||
|
||||
Transcript t = Transcript.start("02-flyway-checksum-mismatch",
|
||||
"Flyway: editing an already-applied migration file");
|
||||
|
||||
ApplicationContextRunner runner = new ApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of(DataSourceAutoConfiguration.class, FlywayAutoConfiguration.class))
|
||||
.withPropertyValues(
|
||||
"spring.datasource.url=jdbc:h2:file:" + db,
|
||||
"spring.datasource.username=sa",
|
||||
"spring.flyway.locations=filesystem:" + migrations);
|
||||
|
||||
t.section("first startup — V1 applied as originally written");
|
||||
runner.run(ctx -> {
|
||||
assertThat(ctx).hasNotFailed();
|
||||
t.line("context started cleanly, V1__init.sql applied");
|
||||
});
|
||||
|
||||
// Someone "just tweaks" the already-applied migration instead of writing a new one.
|
||||
Files.writeString(v1, "create table widget (id bigint primary key, name varchar(80));\n");
|
||||
|
||||
t.section("second startup — V1__init.sql edited after being applied");
|
||||
runner.run(ctx -> {
|
||||
assertThat(ctx).hasFailed();
|
||||
Throwable failure = ctx.getStartupFailure();
|
||||
String message = rootMessage(failure);
|
||||
t.line(message);
|
||||
assertThat(message).contains("checksum").containsIgnoringCase("mismatch");
|
||||
});
|
||||
|
||||
t.write();
|
||||
}
|
||||
|
||||
private static String rootMessage(Throwable t) {
|
||||
Throwable cause = t;
|
||||
while (cause.getCause() != null) {
|
||||
cause = cause.getCause();
|
||||
}
|
||||
return cause.getClass().getSimpleName() + ": " + cause.getMessage();
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package com.ankurm.dbmigrations.flyway;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.nio.file.FileSystem;
|
||||
import java.nio.file.FileSystems;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.TreeSet;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import com.ankurm.dbmigrations.Transcript;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* {@code undo} ({@link FlywayUndoTest}) is not a special case — it is one of eight commands that
|
||||
* ship as do-nothing stubs in {@code flyway-core}. This test walks the actual jar on the test
|
||||
* classpath and lists every class under {@code org.flywaydb.core.internal.proprietaryStubs}, so
|
||||
* the list below is read off the artifact Maven Central serves, not copied from a web page.
|
||||
* See docs/07-why-there-is-no-undo.md.
|
||||
*/
|
||||
class FlywayCommunityCommandSurfaceTest {
|
||||
|
||||
@Test
|
||||
void listsEveryProprietaryStubOnTheClasspath() throws Exception {
|
||||
String pkg = "org/flywaydb/core/internal/proprietaryStubs/";
|
||||
URI jarUri = requireOnClasspath(pkg);
|
||||
|
||||
List<String> stubs;
|
||||
try (FileSystem fs = FileSystems.newFileSystem(jarUri, java.util.Map.of())) {
|
||||
Path root = fs.getPath("/" + pkg);
|
||||
try (Stream<Path> walk = Files.list(root)) {
|
||||
stubs = walk.map(p -> p.getFileName().toString())
|
||||
.filter(n -> n.endsWith("Stub.class"))
|
||||
.map(n -> n.replace("CommandExtensionStub.class", ""))
|
||||
.collect(Collectors.toCollection(TreeSet::new))
|
||||
.stream().toList();
|
||||
}
|
||||
}
|
||||
|
||||
Transcript t = Transcript.start("07-flyway-proprietary-stub-commands",
|
||||
"Flyway: every command name that resolves to a Redgate-edition-required stub in flyway-core");
|
||||
stubs.forEach(name -> t.line("- " + name.toLowerCase()));
|
||||
t.write();
|
||||
|
||||
assertThat(stubs).contains("Undo", "Diff", "Check", "Deploy", "Generate", "Model", "Prepare", "Auth");
|
||||
}
|
||||
|
||||
private static URI requireOnClasspath(String resourcePackage) throws IOException, URISyntaxException {
|
||||
URI uri = FlywayCommunityCommandSurfaceTest.class.getClassLoader()
|
||||
.getResource(resourcePackage)
|
||||
.toURI();
|
||||
assertThat(uri.getScheme()).as("expected the proprietary stub package inside a jar on the test classpath")
|
||||
.isEqualTo("jar");
|
||||
return uri;
|
||||
}
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
package com.ankurm.dbmigrations.flyway;
|
||||
|
||||
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 org.flywaydb.core.Flyway;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Two application instances started at the same moment against the same database — the ordinary
|
||||
* rolling-deploy case. Flyway's own locking (a row lock on its schema history table, not an
|
||||
* external coordinator) is what keeps them from racing. See docs/08-concurrent-startup-and-locking.md.
|
||||
*/
|
||||
class FlywayConcurrentMigrateTest {
|
||||
|
||||
@Test
|
||||
void twoInstancesMigratingAtOnceAreSerializedNotDuplicated(@TempDir Path tmp) throws Exception {
|
||||
Path db = tmp.resolve("concurrent-demo");
|
||||
String url = "jdbc:h2:file:" + db + ";AUTO_SERVER=TRUE";
|
||||
|
||||
ExecutorService pool = Executors.newFixedThreadPool(2);
|
||||
Instant start = Instant.now();
|
||||
try {
|
||||
Future<Long> instanceA = pool.submit(() -> runMigrate(url));
|
||||
Future<Long> instanceB = pool.submit(() -> runMigrate(url));
|
||||
long millisA = instanceA.get();
|
||||
long millisB = instanceB.get();
|
||||
Duration total = Duration.between(start, Instant.now());
|
||||
|
||||
Transcript t = Transcript.start("08-flyway-concurrent-lock",
|
||||
"Flyway: two instances calling migrate() at the same moment");
|
||||
t.line("instance A migrate() took " + millisA + "ms");
|
||||
t.line("instance B migrate() took " + millisB + "ms");
|
||||
t.line("wall-clock time for both, run concurrently: " + total.toMillis() + "ms");
|
||||
t.section("flyway_schema_history after both finished");
|
||||
try (Connection conn = DriverManager.getConnection(url, "sa", "")) {
|
||||
String history = DbDump.table(conn,
|
||||
"select \"installed_rank\", \"version\", \"description\", \"success\" from \"flyway_schema_history\" order by \"installed_rank\"");
|
||||
t.line(history);
|
||||
// Java-based migrations keep the class name as-is (no underscore-to-space filename
|
||||
// conversion) — the description here is literally "SlowMigration".
|
||||
assertThat(history.lines().filter(l -> l.contains("SlowMigration")).count())
|
||||
.as("the slow migration must have run exactly once despite two concurrent migrate() calls")
|
||||
.isEqualTo(1);
|
||||
}
|
||||
t.write();
|
||||
|
||||
// Both instances were submitted at the same instant, so a sum-vs-wall-clock
|
||||
// comparison is the wrong shape here: the loser's migrate() call is blocked waiting
|
||||
// for the winner's row lock for almost the whole 800ms sleep, then finds nothing left
|
||||
// to do and returns almost instantly — so the WALL CLOCK for both together is close to
|
||||
// one migration's duration, not their sum. The direct proof of serialization is that
|
||||
// the loser's own call took nearly as long as the winner's, instead of returning near
|
||||
// instantly the way an unlocked, do-nothing migrate() call would.
|
||||
assertThat(millisA).isGreaterThan(600);
|
||||
assertThat(millisB).isGreaterThan(600);
|
||||
}
|
||||
finally {
|
||||
pool.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
private static long runMigrate(String url) {
|
||||
Instant t0 = Instant.now();
|
||||
Flyway flyway = Flyway.configure()
|
||||
.dataSource(url, "sa", "")
|
||||
.javaMigrations(new V1__SlowMigration())
|
||||
.locations("classpath:db/no-sql-migrations-here")
|
||||
.load();
|
||||
flyway.migrate();
|
||||
return Duration.between(t0, Instant.now()).toMillis();
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package com.ankurm.dbmigrations.flyway;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.sql.Connection;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||
import org.springframework.boot.flyway.autoconfigure.FlywayAutoConfiguration;
|
||||
import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
|
||||
import com.ankurm.dbmigrations.DbDump;
|
||||
import com.ankurm.dbmigrations.Transcript;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* The smallest thing that works: two versioned migrations, run through Boot's own
|
||||
* {@code FlywayAutoConfiguration} rather than the Flyway API directly, because that is what
|
||||
* every Spring Boot application actually exercises. See docs/02-anatomy-of-a-migration-run.md.
|
||||
*/
|
||||
class FlywayHappyPathTest {
|
||||
|
||||
@Test
|
||||
void appliesVersionedMigrationsInOrder(@TempDir Path tmp) throws Exception {
|
||||
Path db = tmp.resolve("happy-path");
|
||||
Transcript t = Transcript.start("01-flyway-happy-path", "Flyway: two versioned migrations, applied on startup");
|
||||
|
||||
new ApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of(DataSourceAutoConfiguration.class, FlywayAutoConfiguration.class))
|
||||
.withPropertyValues(
|
||||
"spring.datasource.url=jdbc:h2:file:" + db,
|
||||
"spring.datasource.username=sa",
|
||||
"spring.flyway.locations=classpath:db/migration")
|
||||
.run(ctx -> {
|
||||
assertThat(ctx).hasNotFailed();
|
||||
DataSource ds = ctx.getBean(DataSource.class);
|
||||
try (Connection conn = ds.getConnection()) {
|
||||
t.section("flyway_schema_history");
|
||||
String history = DbDump.table(conn,
|
||||
"select \"installed_rank\", \"version\", \"description\", \"type\", \"checksum\", \"success\" "
|
||||
+ "from \"flyway_schema_history\" order by \"installed_rank\"");
|
||||
t.line(history);
|
||||
// Flyway derives the description from the filename by turning underscores into spaces.
|
||||
assertThat(history).contains("1").contains("2").contains("create customer").contains("seed customer");
|
||||
|
||||
t.section("customer table");
|
||||
String customers = DbDump.table(conn, "select id, name, email from customer order by id");
|
||||
t.line(customers);
|
||||
assertThat(customers).contains("Ada Lovelace").contains("Grace Hopper");
|
||||
}
|
||||
});
|
||||
|
||||
t.write();
|
||||
}
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
package com.ankurm.dbmigrations.flyway;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.Connection;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||
import org.springframework.boot.flyway.autoconfigure.FlywayAutoConfiguration;
|
||||
import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
|
||||
import com.ankurm.dbmigrations.DbDump;
|
||||
import com.ankurm.dbmigrations.Transcript;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Two branches both add the next migration and both call it something different: one team ships
|
||||
* V3, the other's V2 lands later after a slow-to-merge pull request. See
|
||||
* docs/04-out-of-order-migrations.md for what {@code outOfOrder} actually controls.
|
||||
*/
|
||||
class FlywayOutOfOrderTest {
|
||||
|
||||
@Test
|
||||
void aLateArrivingLowerVersionIsSkippedUnlessOutOfOrderIsEnabled(@TempDir Path tmp) throws Exception {
|
||||
Path db = tmp.resolve("out-of-order-demo");
|
||||
Path migrations = tmp.resolve("migrations");
|
||||
Files.createDirectories(migrations);
|
||||
Files.writeString(migrations.resolve("V1__init.sql"),
|
||||
"create table ledger (id bigint primary key, note varchar(100));\n");
|
||||
Files.writeString(migrations.resolve("V3__add_note_index.sql"),
|
||||
"create index idx_ledger_note on ledger(note);\n");
|
||||
|
||||
Transcript t = Transcript.start("03-flyway-out-of-order",
|
||||
"Flyway: a lower-versioned migration lands after a higher one is already applied");
|
||||
|
||||
t.section("first startup — V1 and V3 only");
|
||||
new ApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of(DataSourceAutoConfiguration.class, FlywayAutoConfiguration.class))
|
||||
.withPropertyValues(
|
||||
"spring.datasource.url=jdbc:h2:file:" + db,
|
||||
"spring.datasource.username=sa",
|
||||
"spring.flyway.locations=filesystem:" + migrations)
|
||||
.run(ctx -> {
|
||||
assertThat(ctx).hasNotFailed();
|
||||
t.line("applied: V1, V3");
|
||||
});
|
||||
|
||||
// V2 merges late — its version number is lower than the V3 already applied to every environment.
|
||||
Files.writeString(migrations.resolve("V2__add_note_length_check.sql"),
|
||||
"alter table ledger add constraint chk_note_length check (char_length(note) <= 100);\n");
|
||||
|
||||
t.section("second startup — outOfOrder=false (Flyway default)");
|
||||
new ApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of(DataSourceAutoConfiguration.class, FlywayAutoConfiguration.class))
|
||||
.withPropertyValues(
|
||||
"spring.datasource.url=jdbc:h2:file:" + db,
|
||||
"spring.datasource.username=sa",
|
||||
"spring.flyway.locations=filesystem:" + migrations,
|
||||
"spring.flyway.out-of-order=false")
|
||||
.run(ctx -> {
|
||||
// Default (outOfOrder=false): Flyway does not quietly skip V2 — it refuses to
|
||||
// start at all. Validation runs before migration and treats a lower-versioned
|
||||
// migration than the highest already applied as a validation failure.
|
||||
assertThat(ctx).hasFailed();
|
||||
String message = rootMessage(ctx.getStartupFailure());
|
||||
t.line("context failed to start: " + message);
|
||||
assertThat(message).contains("outOfOrder").as("Flyway's own message names the fix");
|
||||
|
||||
// Autoconfiguration tears the DataSource bean down along with everything else
|
||||
// on a refresh failure — reopen the same file directly to see what landed.
|
||||
try (Connection conn = java.sql.DriverManager.getConnection("jdbc:h2:file:" + db, "sa", "")) {
|
||||
t.line(DbDump.table(conn,
|
||||
"select \"installed_rank\", \"version\", \"description\", \"success\" from \"flyway_schema_history\" order by \"installed_rank\""));
|
||||
}
|
||||
});
|
||||
|
||||
t.section("third startup — outOfOrder=true");
|
||||
new ApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of(DataSourceAutoConfiguration.class, FlywayAutoConfiguration.class))
|
||||
.withPropertyValues(
|
||||
"spring.datasource.url=jdbc:h2:file:" + db,
|
||||
"spring.datasource.username=sa",
|
||||
"spring.flyway.locations=filesystem:" + migrations,
|
||||
"spring.flyway.out-of-order=true")
|
||||
.run(ctx -> {
|
||||
assertThat(ctx).hasNotFailed();
|
||||
DataSource ds = ctx.getBean(DataSource.class);
|
||||
try (Connection conn = ds.getConnection()) {
|
||||
String history = DbDump.table(conn,
|
||||
"select \"installed_rank\", \"version\", \"description\", \"success\" from \"flyway_schema_history\" order by \"installed_rank\"");
|
||||
t.line(history);
|
||||
assertThat(history).contains("add note length check");
|
||||
}
|
||||
});
|
||||
|
||||
t.write();
|
||||
}
|
||||
|
||||
private static String rootMessage(Throwable t) {
|
||||
Throwable cause = t;
|
||||
while (cause.getCause() != null) {
|
||||
cause = cause.getCause();
|
||||
}
|
||||
return cause.getClass().getSimpleName() + ": " + cause.getMessage();
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
package com.ankurm.dbmigrations.flyway;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.Connection;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||
import org.springframework.boot.flyway.autoconfigure.FlywayAutoConfiguration;
|
||||
import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
|
||||
import com.ankurm.dbmigrations.DbDump;
|
||||
import com.ankurm.dbmigrations.Transcript;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* A repeatable migration ({@code R__}) is not versioned at all — Flyway reruns it whenever its
|
||||
* checksum changes, no matter where it sits relative to versioned migrations. That is a genuinely
|
||||
* different rule from {@link FlywayOutOfOrderTest}'s versioned ones. See docs/05-repeatable-migrations.md.
|
||||
*/
|
||||
class FlywayRepeatableTest {
|
||||
|
||||
@Test
|
||||
void aRepeatableMigrationRerunsWhenItsContentChanges(@TempDir Path tmp) throws Exception {
|
||||
Path db = tmp.resolve("repeatable-demo");
|
||||
Path migrations = tmp.resolve("migrations");
|
||||
Files.createDirectories(migrations);
|
||||
Files.writeString(migrations.resolve("V1__init.sql"),
|
||||
"create table invoice (id bigint primary key, amount_cents bigint, status varchar(20));\n"
|
||||
+ "insert into invoice values (1, 5000, 'PAID');\n"
|
||||
+ "insert into invoice values (2, 3000, 'PENDING');\n");
|
||||
Path view = migrations.resolve("R__invoice_summary_view.sql");
|
||||
Files.writeString(view,
|
||||
"create or replace view invoice_summary as select status, count(*) as cnt from invoice group by status;\n");
|
||||
|
||||
ApplicationContextRunner runner = new ApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of(DataSourceAutoConfiguration.class, FlywayAutoConfiguration.class))
|
||||
.withPropertyValues(
|
||||
"spring.datasource.url=jdbc:h2:file:" + db,
|
||||
"spring.datasource.username=sa",
|
||||
"spring.flyway.locations=filesystem:" + migrations);
|
||||
|
||||
Transcript t = Transcript.start("04-flyway-repeatable",
|
||||
"Flyway: a repeatable migration reruns on checksum change alone");
|
||||
|
||||
t.section("first startup");
|
||||
runner.run(ctx -> {
|
||||
assertThat(ctx).hasNotFailed();
|
||||
DataSource ds = ctx.getBean(DataSource.class);
|
||||
try (Connection conn = ds.getConnection()) {
|
||||
t.line(DbDump.table(conn,
|
||||
"select \"installed_rank\", \"version\", \"description\", \"type\", \"checksum\" from \"flyway_schema_history\" order by \"installed_rank\""));
|
||||
t.section("invoice_summary right after creation");
|
||||
t.line(DbDump.table(conn, "select status, cnt from invoice_summary order by status"));
|
||||
}
|
||||
});
|
||||
|
||||
// Widen the view without touching the version numbers at all.
|
||||
Files.writeString(view,
|
||||
"create or replace view invoice_summary as select status, count(*) as cnt, sum(amount_cents) as total_cents "
|
||||
+ "from invoice group by status;\n");
|
||||
|
||||
t.section("second startup — only R__invoice_summary_view.sql changed");
|
||||
runner.run(ctx -> {
|
||||
assertThat(ctx).hasNotFailed();
|
||||
DataSource ds = ctx.getBean(DataSource.class);
|
||||
try (Connection conn = ds.getConnection()) {
|
||||
String history = DbDump.table(conn,
|
||||
"select \"installed_rank\", \"version\", \"description\", \"type\", \"checksum\" from \"flyway_schema_history\" order by \"installed_rank\"");
|
||||
t.line(history);
|
||||
assertThat(history.lines().filter(l -> l.contains("invoice summary view")).count()).isEqualTo(2);
|
||||
|
||||
t.section("invoice_summary after the repeatable migration reran");
|
||||
String summary = DbDump.table(conn, "select status, cnt, total_cents from invoice_summary order by status");
|
||||
t.line(summary);
|
||||
assertThat(summary).contains("TOTAL_CENTS");
|
||||
}
|
||||
});
|
||||
|
||||
t.write();
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package com.ankurm.dbmigrations.flyway;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import org.flywaydb.core.Flyway;
|
||||
import org.flywaydb.core.api.FlywayException;
|
||||
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 Flyway.undo()} is a real, public method on the Community jar — it compiles, and nothing
|
||||
* about its signature says it will not run. What actually resolves it at runtime is a stub. See
|
||||
* docs/07-why-there-is-no-undo.md for how {@code flyway-core}'s own bytecode was read to confirm
|
||||
* this rather than trusting Redgate's marketing pages, which was the point of this test.
|
||||
*/
|
||||
class FlywayUndoTest {
|
||||
|
||||
@Test
|
||||
void undoOnTheCommunityJarThrowsAnEditionRequiredException(@TempDir Path tmp) throws Exception {
|
||||
Path db = tmp.resolve("undo-demo");
|
||||
Path migrations = tmp.resolve("migrations");
|
||||
Files.createDirectories(migrations);
|
||||
Files.writeString(migrations.resolve("V1__init.sql"), "create table t (id int primary key);\n");
|
||||
|
||||
Flyway flyway = Flyway.configure()
|
||||
.dataSource("jdbc:h2:file:" + db, "sa", "")
|
||||
.locations("filesystem:" + migrations)
|
||||
.load();
|
||||
flyway.migrate();
|
||||
|
||||
Transcript t = Transcript.start("06-flyway-undo-teams-required",
|
||||
"Flyway: calling the public undo() API on the Community jar");
|
||||
|
||||
FlywayException ex = catchFlywayException(flyway);
|
||||
t.line("flyway.undo() threw: " + ex.getClass().getName());
|
||||
t.line("message: " + ex.getMessage());
|
||||
t.write();
|
||||
|
||||
assertThat(ex.getClass().getSimpleName()).isEqualTo("FlywayRedgateEditionRequiredException");
|
||||
assertThat(ex.getMessage()).containsIgnoringCase("undo");
|
||||
}
|
||||
|
||||
private static FlywayException catchFlywayException(Flyway flyway) {
|
||||
try {
|
||||
flyway.undo();
|
||||
}
|
||||
catch (FlywayException ex) {
|
||||
return ex;
|
||||
}
|
||||
throw new AssertionError("expected flyway.undo() to throw FlywayException on the Community jar");
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.ankurm.dbmigrations.flyway;
|
||||
|
||||
import org.flywaydb.core.api.migration.BaseJavaMigration;
|
||||
import org.flywaydb.core.api.migration.Context;
|
||||
|
||||
/**
|
||||
* A deliberately slow Java-based migration used only by {@link FlywayConcurrentMigrateTest} to
|
||||
* widen the window in which a second {@code migrate()} call can try to run at the same time.
|
||||
* Flyway derives the version (1) from this class name the same way it would from
|
||||
* {@code V1__SlowMigration.sql} — but the description is the raw remainder, "SlowMigration",
|
||||
* with no underscore-to-space conversion. That conversion is filename-specific.
|
||||
*/
|
||||
public class V1__SlowMigration extends BaseJavaMigration {
|
||||
|
||||
@Override
|
||||
public void migrate(Context context) throws Exception {
|
||||
try (var st = context.getConnection().createStatement()) {
|
||||
st.execute("create table slow_migration_marker (id int primary key)");
|
||||
}
|
||||
Thread.sleep(800);
|
||||
}
|
||||
}
|
||||
+121
@@ -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();
|
||||
}
|
||||
}
|
||||
+68
@@ -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();
|
||||
}
|
||||
}
|
||||
+35
@@ -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();
|
||||
}
|
||||
}
|
||||
+73
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+77
@@ -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();
|
||||
}
|
||||
}
|
||||
+75
@@ -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");
|
||||
}
|
||||
}
|
||||
+24
@@ -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);
|
||||
}
|
||||
}
|
||||
+48
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user