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
+1
View File
@@ -21,6 +21,7 @@ files.
| [`caching/`](caching) | [The Spring Cache Abstraction: @Cacheable, @CacheEvict, Key Generators and the Self-Invocation Trap](https://ankurm.com/spring-cache-abstraction-cacheable-cacheevict-self-invocation-trap/) | the self-invocation trap measured four ways, the key collision `SimpleKeyGenerator` makes easy, eviction timing under a thrown exception, a rollback the cache keeps, and where this sits next to Hibernate's L2 cache |
| [`spring-batch/`](spring-batch) | [Spring Batch on Boot 4.1: Jobs, Steps, Chunk Processing and Restartability](https://ankurm.com/) | a job that fails mid-chunk and resumes exactly where it left off across two separate JVMs, skip vs. restart on the same poisoned row, the resourceless job repository that forgets a restart ever happened, and the `chunk(int)` vs `chunk(int, tx)` builder split |
| [`spring-batch-partitioning/`](spring-batch-partitioning) | [Spring Batch Partitioning and Parallel Steps: Scaling a 10-Million-Row Job](https://ankurm.com/) | the real grid-size sweep at 10M and 300K rows (best speedup 1.42x, on 2 cores), `MultiResourcePartitioner` ignoring gridSize entirely, a rejected partition's `StepExecution` stuck at `STARTING` forever, and Spring Batch 6.0's new `JobOperator#recover` unsticking it |
| [`db-migrations-flyway-liquibase/`](db-migrations-flyway-liquibase) | [Flyway vs Liquibase for Spring Boot 4: Migrations, Rollbacks and Baselines](https://ankurm.com/) | Flyway Community's `undo` throwing `FlywayRedgateEditionRequiredException` at runtime, a real Liquibase 5.0.3 filename-caching defect that produces a phantom successful run, Liquibase's 10-second default lock-poll rate versus Flyway's near-instant row lock, the FSL license change and its ASF/Keycloak fallout, and what actually happens when both tools are enabled against one database |
Articles whose text is kept here rather than only on the blog have it under
`<directory>/post/``post.md` for the body and `meta.md` for the title, excerpt and
+97
View File
@@ -0,0 +1,97 @@
# db-migrations-flyway-liquibase
Companion code for **[Flyway vs Liquibase for Spring Boot 4: Migrations, Rollbacks and
Baselines](https://ankurm.com/)** — every claim in that post traces to a test in this module and a
transcript in [`docs/output/`](docs/output).
## Versions
| Component | Version |
|---|---|
| Spring Boot | 4.1.1 |
| Flyway (via `spring-boot-starter-flyway`) | 12.4.0 |
| Liquibase (via `spring-boot-starter-liquibase`) | 5.0.3 |
| Database | H2 2.4.240 (file-based, `AUTO_SERVER=TRUE`) |
| JDK | 25 |
## Quickstart
```bash
mvn -DskipTests package
./scripts/run.sh # default profile: Flyway only, clean startup
./scripts/run.sh both-naive # both enabled, no baseline config — fails to start on purpose
./scripts/run.sh both-fixed # both enabled, Flyway baselined at version 0 — starts cleanly
mvn test # regenerates every transcript in docs/output/
```
With the app running, hit the diagnostics endpoint to see both tools' bookkeeping tables live:
```bash
curl -s localhost:8080/diag/migrations | jq .
```
## Scenarios (Spring profiles)
| Profile | What it demonstrates | Config |
|---|---|---|
| *(default)* | Flyway-only startup against a fresh database | `spring.flyway.enabled=true`, `spring.liquibase.enabled=false` |
| `both-naive` | Enabling both starters with no other configuration — fails on startup | see [chapter 14](docs/14-running-both-at-once.md) |
| `both-fixed` | Both enabled, Flyway told to baseline at version 0 — coexists correctly | `spring.flyway.baseline-on-migrate=true`, `spring.flyway.baseline-version=0` |
## Endpoints
| Endpoint | Purpose |
|---|---|
| `GET /diag/migrations` | Plain-JDBC dump of both tools' tracking tables and the live table list — **delete before shipping** (see [chapter 15](docs/15-production-checklist.md)) |
| `GET /actuator/flyway`, `GET /actuator/liquibase` | Boot's own actuator endpoints, exposed in [`application.yml`](src/main/resources/application.yml) |
## Documentation
1. [The problem and the mental model](docs/01-the-problem-and-mental-model.md)
2. [Anatomy of a migration run](docs/02-anatomy-of-a-migration-run.md)
3. [Checksum validation](docs/03-checksum-validation.md)
4. [Out-of-order migrations](docs/04-out-of-order-migrations.md)
5. [Repeatable migrations](docs/05-repeatable-migrations.md)
6. [Baselining an existing database](docs/06-baselining-an-existing-database.md)
7. [Why there is no undo](docs/07-why-there-is-no-undo.md)
8. [Concurrent startup and locking](docs/08-concurrent-startup-and-locking.md)
9. [Liquibase: anatomy of an update](docs/09-liquibase-anatomy-of-an-update.md)
10. [Rollback: auto-generated vs explicit](docs/10-rollback-auto-generated-vs-explicit.md)
11. [Liquibase locking](docs/11-liquibase-locking.md)
12. [The OSS license service](docs/12-the-oss-license-service.md)
13. [The FSL license change](docs/13-the-fsl-license-change.md)
14. [Running both at once](docs/14-running-both-at-once.md)
15. [Production checklist](docs/15-production-checklist.md)
## Captured output
Every number quoted in the post and in the chapters above comes from a committed transcript in
[`docs/output/`](docs/output), regenerated by `mvn test` via the [`Transcript`](src/test/java/com/ankurm/dbmigrations/Transcript.java)
helper — the tests assert the same numbers they print, so a transcript going stale fails the build:
| File | Test |
|---|---|
| [`01-flyway-happy-path.txt`](docs/output/01-flyway-happy-path.txt) | [`FlywayHappyPathTest`](src/test/java/com/ankurm/dbmigrations/flyway/FlywayHappyPathTest.java) |
| [`02-flyway-checksum-mismatch.txt`](docs/output/02-flyway-checksum-mismatch.txt) | [`FlywayChecksumMismatchTest`](src/test/java/com/ankurm/dbmigrations/flyway/FlywayChecksumMismatchTest.java) |
| [`03-flyway-out-of-order.txt`](docs/output/03-flyway-out-of-order.txt) | [`FlywayOutOfOrderTest`](src/test/java/com/ankurm/dbmigrations/flyway/FlywayOutOfOrderTest.java) |
| [`04-flyway-repeatable.txt`](docs/output/04-flyway-repeatable.txt) | [`FlywayRepeatableTest`](src/test/java/com/ankurm/dbmigrations/flyway/FlywayRepeatableTest.java) |
| [`05-flyway-baseline.txt`](docs/output/05-flyway-baseline.txt) | [`FlywayBaselineTest`](src/test/java/com/ankurm/dbmigrations/flyway/FlywayBaselineTest.java) |
| [`06-flyway-undo-teams-required.txt`](docs/output/06-flyway-undo-teams-required.txt) | [`FlywayUndoTest`](src/test/java/com/ankurm/dbmigrations/flyway/FlywayUndoTest.java) |
| [`07-flyway-proprietary-stub-commands.txt`](docs/output/07-flyway-proprietary-stub-commands.txt) | [`FlywayCommunityCommandSurfaceTest`](src/test/java/com/ankurm/dbmigrations/flyway/FlywayCommunityCommandSurfaceTest.java) |
| [`08-flyway-concurrent-lock.txt`](docs/output/08-flyway-concurrent-lock.txt) | [`FlywayConcurrentMigrateTest`](src/test/java/com/ankurm/dbmigrations/flyway/FlywayConcurrentMigrateTest.java) |
| [`09-liquibase-happy-path.txt`](docs/output/09-liquibase-happy-path.txt) | [`LiquibaseHappyPathTest`](src/test/java/com/ankurm/dbmigrations/liquibase/LiquibaseHappyPathTest.java) |
| [`10-liquibase-rollback-auto.txt`](docs/output/10-liquibase-rollback-auto.txt) | [`LiquibaseRollbackAutoTest`](src/test/java/com/ankurm/dbmigrations/liquibase/LiquibaseRollbackAutoTest.java) |
| [`11-liquibase-rollback-no-inverse.txt`](docs/output/11-liquibase-rollback-no-inverse.txt) | [`LiquibaseRollbackFailTest`](src/test/java/com/ankurm/dbmigrations/liquibase/LiquibaseRollbackFailTest.java) |
| [`12-liquibase-rollback-explicit.txt`](docs/output/12-liquibase-rollback-explicit.txt) | [`LiquibaseRollbackExplicitTest`](src/test/java/com/ankurm/dbmigrations/liquibase/LiquibaseRollbackExplicitTest.java) |
| [`13-liquibase-lock-contention.txt`](docs/output/13-liquibase-lock-contention.txt) | [`LiquibaseConcurrentUpdateTest`](src/test/java/com/ankurm/dbmigrations/liquibase/LiquibaseConcurrentUpdateTest.java) |
| [`14-liquibase-oss-license-service.txt`](docs/output/14-liquibase-oss-license-service.txt) | [`LiquibaseLicenseServiceTest`](src/test/java/com/ankurm/dbmigrations/liquibase/LiquibaseLicenseServiceTest.java) |
| [`15-both-together-same-datasource.txt`](docs/output/15-both-together-same-datasource.txt) | [`BothTogetherTest`](src/test/java/com/ankurm/dbmigrations/BothTogetherTest.java) |
## Findings worth the trip
- Flyway Community's `undo`, `diff`, `check`, `deploy`, `generate`, `model`, `prepare` and `auth` commands all **compile fine** and throw `FlywayRedgateEditionRequiredException` only at runtime — there is no working rollback in Flyway Community at all ([chapter 7](docs/07-why-there-is-no-undo.md)).
- H2 case-folds unquoted identifiers to uppercase, so an unquoted query against `flyway_schema_history` (created and queried by Flyway using quoted lowercase) silently finds nothing — a real bug this module's own diagnostics endpoint had and fixed ([chapter 2](docs/02-anatomy-of-a-migration-run.md)).
- Liquibase's default lock-poll rate is 10 seconds, confirmed by decompiling `GlobalConfiguration`'s bytecode — a losing instance can wait up to ten seconds for sub-second work, unlike Flyway's near-instant row-lock release ([chapter 11](docs/11-liquibase-locking.md)).
- A genuine Liquibase 5.0.3 defect: reusing the same simple changelog filename for two logically different changelogs causes a phantom "successful" run where the changeset never actually executes ([chapter 11](docs/11-liquibase-locking.md)).
- Liquibase Community 5.0 shipped under the Functional Source License, not Apache 2.0 — a real, ongoing compliance question for projects like Apache Fineract (ASF LEGAL-721) and Keycloak (GitHub #43391) ([chapter 13](docs/13-the-fsl-license-change.md)).
- Enabling both Flyway and Liquibase against one database fails to start by default, and fixing it does not integrate them — it just gets you two independent bookkeepers, each blind to the other's tables ([chapter 14](docs/14-running-both-at-once.md)).
@@ -0,0 +1,75 @@
# 1. The problem, and the smallest correct mental model
[Index](../README.md) · Next: [2. Anatomy of a Flyway migration run →](02-anatomy-of-a-migration-run.md)
Every app with a database eventually needs a second table added, or a column renamed, or a
default changed — and it needs that to happen the same way on the laptop where it was written,
on the CI database that only exists for four minutes, and on production, which nobody wants to
SSH into and run SQL by hand against. A migration tool's whole job is to make "the same way"
true without a human re-typing SQL in three places.
Both tools this module compares solve that with the same core idea: number or name every schema
change, keep a table *inside the database itself* that records which ones have already run, and
on startup, apply whatever is missing. That tracking table is the single most important thing to
understand before either tool's specific behaviour makes sense — it is the reason a second
`mvn spring-boot:run` doesn't re-run migration 1, and it is the reason two things you're about to
read about (checksum validation, and locking) exist at all.
<svg viewBox="0 0 720 230" xmlns="http://www.w3.org/2000/svg" font-family="monospace" font-size="13">
<rect x="10" y="10" width="220" height="60" rx="6" fill="#eef2ff" stroke="#4f46e5"/>
<text x="120" y="35" text-anchor="middle">migration files</text>
<text x="120" y="52" text-anchor="middle" font-size="11">V1__..., V2__...</text>
<path d="M230 40 L280 40" stroke="#334155" stroke-width="2" marker-end="url(#arrow)"/>
<rect x="280" y="10" width="220" height="60" rx="6" fill="#ecfeff" stroke="#0891b2"/>
<text x="390" y="35" text-anchor="middle">the tool at startup</text>
<text x="390" y="52" text-anchor="middle" font-size="11">diff files vs. history</text>
<path d="M500 40 L550 40" stroke="#334155" stroke-width="2" marker-end="url(#arrow)"/>
<rect x="550" y="10" width="160" height="60" rx="6" fill="#fef9c3" stroke="#ca8a04"/>
<text x="630" y="35" text-anchor="middle">your database</text>
<text x="630" y="52" text-anchor="middle" font-size="11">applies what's new</text>
<path d="M390 70 L390 110" stroke="#334155" stroke-width="2" marker-end="url(#arrow)"/>
<rect x="270" y="110" width="240" height="60" rx="6" fill="#fdf2f8" stroke="#be185d"/>
<text x="390" y="135" text-anchor="middle">tracking table, in that database</text>
<text x="390" y="152" text-anchor="middle" font-size="11">flyway_schema_history / DATABASECHANGELOG</text>
<path d="M270 140 L20 140 L20 40 L 10 40" stroke="#334155" stroke-width="1.5" fill="none" stroke-dasharray="4 3" marker-end="url(#arrow)"/>
<text x="130" y="190" font-size="11" fill="#475569">read back on the NEXT startup — this is what "already applied" means</text>
<defs>
<marker id="arrow" markerWidth="8" markerHeight="8" refX="6" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#334155"/></marker>
</defs>
</svg>
The tracking table is not a cache or a convenience — it is the source of truth the tool consults
before touching your schema at all, and it lives in the same database the schema lives in, so it
survives redeploys, restarts, and different machines running the same migration set. Flyway's is
called `flyway_schema_history`; Liquibase's is called `DATABASECHANGELOG`, with a second table,
`DATABASECHANGELOGLOCK`, purely for coordinating concurrent runs (chapters
[8](08-concurrent-startup-and-locking.md) and [11](11-liquibase-locking.md)).
## What this module actually runs
Everything in this article is one small Spring Boot 4.1.1 module,
[`db-migrations-flyway-liquibase`](..), with both starters on the classpath:
[`spring-boot-starter-flyway`](../pom.xml) and [`spring-boot-starter-liquibase`](../pom.xml). Two
tiny migration sets live side by side — Flyway's under
[`src/main/resources/db/migration`](../src/main/resources/db/migration), Liquibase's as one YAML
changelog at
[`db.changelog-master.yaml`](../src/main/resources/db/changelog/db.changelog-master.yaml) — and
every scenario in the chapters that follow is a real JUnit test using Spring's
[`ApplicationContextRunner`](../src/test/java/com/ankurm/dbmigrations/flyway/FlywayHappyPathTest.java),
which boots a real `ApplicationContext` against a real H2 file database in milliseconds, with no
web server needed. A small [`Transcript`](../src/test/java/com/ankurm/dbmigrations/Transcript.java)
helper writes what each test asserts to a plain text file under
[`docs/output/`](output) — every table and error message quoted in this documentation, and in the
published article, was copied out of one of those committed files, not retyped from memory.
The one part of the module that *is* a running app rather than a test is
[`DbMigrationsApplication`](../src/main/java/com/ankurm/dbmigrations/DbMigrationsApplication.java),
whose only purpose is to expose `/diag/migrations` (chapter [15](15-production-checklist.md)) so
you can watch a real startup's bookkeeping instead of only reading about it.
## Going deeper
- [Flyway: how migrations work](https://documentation.red-gate.com/flyway/flyway-cli-and-api/concepts/migrations) — the vendor's own concept page (`rel="nofollow"`).
- [Liquibase: how it works](https://docs.liquibase.com/concepts/introduction-to-liquibase.html) — the vendor's own concept page (`rel="nofollow"`).
- [Spring Boot 4.1 release notes](https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-4.1-Release-Notes) — for the package relocation both tools' autoconfiguration went through (chapter [2](02-anatomy-of-a-migration-run.md) touches this).
@@ -0,0 +1,99 @@
# 2. Anatomy of a Flyway migration run
[← 1. The problem and mental model](01-the-problem-and-mental-model.md) · [Index](../README.md) · Next: [3. Checksum validation →](03-checksum-validation.md)
The smallest possible Flyway setup is two SQL files and nothing else. This module's are
[`V1__create_customer.sql`](../src/main/resources/db/migration/V1__create_customer.sql) and
[`V2__seed_customer.sql`](../src/main/resources/db/migration/V2__seed_customer.sql), exercised by
[`FlywayHappyPathTest`](../src/test/java/com/ankurm/dbmigrations/flyway/FlywayHappyPathTest.java).
The filename *is* the metadata: `V` for versioned, `1` or `2` as the version, two underscores,
then a description Flyway derives straight from the rest of the filename.
```sql
create table customer (
id bigint generated by default as identity primary key,
name varchar(120) not null,
email varchar(200) not null unique
);
```
That's the whole of V1. After both files run once, `flyway_schema_history` looks like this
(quoted verbatim from
[`docs/output/01-flyway-happy-path.txt`](output/01-flyway-happy-path.txt)):
```
installed_rank | version | description | type | checksum | success
---------------+---------+-------------------------------------------+-------+-------------+--------
-1 | NULL | << Flyway Schema History table created >> | TABLE | NULL | true
1 | 1 | create customer | SQL | 1461549807 | true
2 | 2 | seed customer | SQL | -1214875726 | true
```
Two things worth noticing, both easy to get wrong if you only skim the docs:
- **The description column drops underscores for spaces.** The file is
`V1__create_customer.sql`; the row says `create customer`, not `create_customer`. This module's
own test assertions got this wrong on the first pass — they were written expecting the
underscore to survive, and the real output corrected them.
- **`checksum` is a signed 32-bit CRC of the file's content**, not a hash of the SQL statements
Flyway ran. Chapter [3](03-checksum-validation.md) is entirely about what happens when that
number stops matching.
<svg viewBox="0 0 700 190" xmlns="http://www.w3.org/2000/svg" font-family="monospace" font-size="13">
<rect x="10" y="70" width="150" height="50" rx="6" fill="#eef2ff" stroke="#4f46e5"/>
<text x="85" y="100" text-anchor="middle">V1__create_customer.sql</text>
<rect x="10" y="140" width="150" height="40" rx="6" fill="#eef2ff" stroke="#4f46e5"/>
<text x="85" y="164" text-anchor="middle">V2__seed_customer.sql</text>
<path d="M170 95 L230 95" stroke="#334155" stroke-width="2" marker-end="url(#a2)"/>
<path d="M170 160 L230 130" stroke="#334155" stroke-width="2" marker-end="url(#a2)"/>
<rect x="230" y="60" width="220" height="100" rx="6" fill="#ecfeff" stroke="#0891b2"/>
<text x="340" y="85" text-anchor="middle">Flyway.migrate()</text>
<text x="340" y="105" text-anchor="middle" font-size="11">for each file, in version order:</text>
<text x="340" y="122" text-anchor="middle" font-size="11">already in history? skip.</text>
<text x="340" y="139" text-anchor="middle" font-size="11">else: run it, record a row.</text>
<path d="M450 110 L520 110" stroke="#334155" stroke-width="2" marker-end="url(#a2)"/>
<rect x="520" y="30" width="170" height="160" rx="6" fill="#fdf2f8" stroke="#be185d"/>
<text x="605" y="55" text-anchor="middle">flyway_schema_history</text>
<text x="540" y="80" font-size="11">rank -1 TABLE</text>
<text x="540" y="100" font-size="11">rank 1 V1 SQL</text>
<text x="540" y="120" font-size="11">rank 2 V2 SQL</text>
<text x="540" y="145" font-size="11" fill="#475569">one row per migration,</text>
<text x="540" y="160" font-size="11" fill="#475569">named lowercase, quoted</text>
<defs><marker id="a2" markerWidth="8" markerHeight="8" refX="6" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#334155"/></marker></defs>
</svg>
## A case-folding trap worth knowing before you write your own query
That last line in the diagram — "named lowercase, quoted" — is not decoration. Flyway creates and
queries its own table using **quoted, lowercase identifiers**:
`"flyway_schema_history"`, `"installed_rank"`, and so on. H2, like most databases, folds
*unquoted* identifiers to uppercase by default. So this innocent-looking query, run from your own
code against the exact same database, finds nothing:
```sql
select installed_rank from flyway_schema_history -- looks for FLYWAY_SCHEMA_HISTORY — not found
```
The fix is to quote it the same way Flyway does:
```sql
select "installed_rank" from "flyway_schema_history"
```
This module's own [`MigrationDiagnosticsController`](../src/main/java/com/ankurm/dbmigrations/web/MigrationDiagnosticsController.java)
hit exactly this while it was being written — the first version queried Flyway's table
unquoted and silently got "table not found" back. Every test in this module that reads
`flyway_schema_history` directly (see
[`FlywayHappyPathTest`](../src/test/java/com/ankurm/dbmigrations/flyway/FlywayHappyPathTest.java)
and the rest of the `flyway` package) quotes it for exactly this reason. Liquibase's own tables
don't need this — its unquoted DDL for `DATABASECHANGELOG` folds consistently with H2's default,
so plain, unquoted `select ... from databasechangelog` works fine (see
[`LiquibaseHappyPathTest`](../src/test/java/com/ankurm/dbmigrations/liquibase/LiquibaseHappyPathTest.java)
in chapter [9](09-liquibase-anatomy-of-an-update.md)). The asymmetry is real, verified by running
both, and it is exactly the kind of thing that costs you twenty minutes the first time you write a
raw report query against a Flyway-managed schema.
## Going deeper
- [Flyway migration naming](https://documentation.red-gate.com/flyway/flyway-cli-and-api/concepts/migrations#naming) — the full naming grammar, including undo and repeatable prefixes (`rel="nofollow"`).
- H2's identifier case sensitivity is documented on the [H2 SQL grammar page](https://h2database.com/html/grammar.html#name) under `DATABASE_TO_LOWER`/`DATABASE_TO_UPPER` (`rel="nofollow"`) — this module doesn't set either, so it runs on H2's default.
@@ -0,0 +1,46 @@
# 3. Checksum validation: why editing an applied migration fails
[← 2. Anatomy of a migration run](02-anatomy-of-a-migration-run.md) · [Index](../README.md) · Next: [4. Out-of-order migrations →](04-out-of-order-migrations.md)
The most common way a migration tool earns its keep is by refusing to do something that would
otherwise fail silently, weeks later, on a machine nobody's watching. This is the simplest version
of that: someone "just tweaks" a migration file that has already run somewhere, instead of writing
a new one.
[`FlywayChecksumMismatchTest`](../src/test/java/com/ankurm/dbmigrations/flyway/FlywayChecksumMismatchTest.java)
starts a context against `V1__init.sql`, lets it apply cleanly, then edits that same file on disk
— widening a `varchar(50)` to `varchar(80)` — and starts a second context against the same
database. Flyway's default (`spring.flyway.validate-on-migrate=true`) compares every applied
migration's recorded checksum against the file's current checksum *before* attempting to migrate
anything else, and the second startup fails outright:
```
FlywayValidateException: Validate failed: Migrations have failed validation
Migration checksum mismatch for migration version 1
-> Applied to database : -712784830
-> Resolved locally : 756220147
Either revert the changes to the migration, or run repair to update the schema history.
```
(quoted verbatim from
[`docs/output/02-flyway-checksum-mismatch.txt`](output/02-flyway-checksum-mismatch.txt))
That message is doing real work: it's telling you the checksum stored in `flyway_schema_history`
for version 1 no longer matches the file Flyway just read off disk. Nothing about the *schema* is
inconsistent yet — the failure happens before any SQL runs, which is the entire point. A team that
lets this slide by running `flyway repair` out of habit is telling Flyway "the file changed on
purpose, update your record" — which is right for a comment or formatting fix, and very wrong for
"I need this table to actually have the new column now", which needs a new `V3__...` file, not a
rewrite of `V1`.
## What checksum validation is not
It is not a guarantee that the *applied* schema matches the *current* file. Flyway checks the file
against what it already recorded, not against the live schema — if someone hand-edits the table
after the fact, nothing here notices. It's a version-control safety net for the migration files
themselves, not a schema-drift detector.
## Going deeper
- [`spring.flyway.validate-on-migrate`](https://docs.spring.io/spring-boot/appendix/application-properties/index.html#application-properties.data-migration.spring.flyway.validate-on-migrate) in the Spring Boot configuration reference (`rel="nofollow"`) — `true` by default.
- [Flyway's `repair` command](https://documentation.red-gate.com/flyway/flyway-cli-and-api/commands/repair) (`rel="nofollow"`) — what actually happens to `flyway_schema_history` when you run it, and why it's a deliberate, logged action rather than something to script into a startup hook.
@@ -0,0 +1,68 @@
# 4. Out-of-order migrations
[← 3. Checksum validation](03-checksum-validation.md) · [Index](../README.md) · Next: [5. Repeatable migrations →](05-repeatable-migrations.md)
Two branches, both adding "the next migration": one ships `V3` and merges quickly, the other's
`V2` sits in a slow-to-review pull request and lands afterward. By the time `V2` merges, every
environment that deployed the first branch already has `V3` applied. What happens when `V2`
finally shows up?
[`FlywayOutOfOrderTest`](../src/test/java/com/ankurm/dbmigrations/flyway/FlywayOutOfOrderTest.java)
reproduces exactly this: `V1` and `V3` apply on a first startup, then `V2__add_note_length_check.sql`
is dropped in afterward, and a second startup runs against the same database.
**The common assumption — that Flyway just quietly skips the late-arriving lower version and
carries on — is wrong.** The whole application fails to start:
```
context failed to start: FlywayValidateException: Validate failed: Migrations have failed validation
Detected resolved migration not applied to database: 2.
To ignore this migration, set -ignoreMigrationPatterns='*:ignored'. To allow executing this migration, set -outOfOrder=true.
```
(from [`docs/output/03-flyway-out-of-order.txt`](output/03-flyway-out-of-order.txt); this test was
originally written expecting the silent-skip behaviour, and the real run corrected it)
Validation runs before migration, and by default (`spring.flyway.out-of-order=false`) a resolved
migration with a version lower than the highest already-applied one is treated as a validation
failure, not a no-op. Nothing gets applied and the context never comes up.
<svg viewBox="0 0 700 170" xmlns="http://www.w3.org/2000/svg" font-family="monospace" font-size="13">
<text x="20" y="25">history: V1 ✓, V3 ✓</text>
<rect x="20" y="40" width="60" height="30" fill="#dcfce7" stroke="#16a34a"/><text x="50" y="60" text-anchor="middle">V1</text>
<rect x="90" y="40" width="60" height="30" fill="#dcfce7" stroke="#16a34a"/><text x="120" y="60" text-anchor="middle">V3</text>
<rect x="160" y="40" width="60" height="30" fill="#fee2e2" stroke="#dc2626" stroke-dasharray="3 2"/><text x="190" y="60" text-anchor="middle">V2?</text>
<text x="260" y="60">out-of-order=false (default)</text>
<path d="M420 55 L470 55" stroke="#334155" stroke-width="2" marker-end="url(#a3)"/>
<rect x="470" y="35" width="200" height="40" rx="6" fill="#fee2e2" stroke="#dc2626"/>
<text x="570" y="60" text-anchor="middle">whole startup fails</text>
<text x="20" y="110">same history, out-of-order=true</text>
<path d="M270 105 L320 105" stroke="#334155" stroke-width="2" marker-end="url(#a3)"/>
<rect x="320" y="85" width="220" height="40" rx="6" fill="#dcfce7" stroke="#16a34a"/>
<text x="430" y="110" text-anchor="middle">V2 runs, slotted in after V3</text>
<text x="20" y="150" font-size="11" fill="#475569">history keeps its real apply order: V1, V3, V2 — not renumbered</text>
<defs><marker id="a3" markerWidth="8" markerHeight="8" refX="6" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#334155"/></marker></defs>
</svg>
Setting `spring.flyway.out-of-order=true` is the fix the error message itself names, and a third
startup in the same test confirms what it actually does: `V2` runs, and the history table keeps it
in *real* application order — rank 3, version 2, sitting after rank 2's version 3 — Flyway does not
retroactively renumber or reorder anything:
```
installed_rank | version | description | success
---------------+---------+-------------------------------------------+--------
-1 | NULL | << Flyway Schema History table created >> | true
1 | 1 | init | true
2 | 3 | add note index | true
3 | 2 | add note length check | true
```
(from the same [transcript](output/03-flyway-out-of-order.txt))
## Going deeper
- **`outOfOrder` is a blunt, global switch** — turning it on doesn't just allow the one late migration you're expecting, it allows *any* lower-numbered migration to slot in from then on. Flyway's own docs call this out as reducing reproducibility, and `docs/output/03-flyway-out-of-order.txt`'s third-startup log line says so verbatim: `outOfOrder mode is active. Migration of schema may not be reproducible.`
- [`ignoreMigrationPatterns`](https://documentation.red-gate.com/flyway/flyway-cli-and-api/configuration/parameters/ignore-migration-patterns) (`rel="nofollow"`) — the error message's other suggested fix, for permanently ignoring a specific migration instead of relaxing ordering globally.
- A team that hits this regularly is usually missing a CI check that fails a PR when its migration's version number is lower than what's already merged to main — cheaper than relying on `outOfOrder` at all.
@@ -0,0 +1,62 @@
# 5. Repeatable migrations
[← 4. Out-of-order migrations](04-out-of-order-migrations.md) · [Index](../README.md) · Next: [6. Baselining an existing database →](06-baselining-an-existing-database.md)
Versioned migrations (`V1__...`) are one-shot: apply once, never again, ever, on this database.
Views, stored procedures and seed-reference-data scripts don't fit that model — you want them to
re-apply whenever their *content* changes, regardless of what version number anything else is at.
That's what a repeatable migration, prefixed `R__` instead of `V<n>__`, is for.
[`FlywayRepeatableTest`](../src/test/java/com/ankurm/dbmigrations/flyway/FlywayRepeatableTest.java)
ships one versioned migration (`V1__init.sql`, creating and seeding an `invoice` table) alongside
[`R__invoice_summary_view.sql`](../src/test/java/com/ankurm/dbmigrations/flyway/FlywayRepeatableTest.java),
a view definition. On the first startup it runs once, like anything else:
```
installed_rank | version | description | type | checksum
---------------+---------+-------------------------------------------+-------+------------
-1 | NULL | << Flyway Schema History table created >> | TABLE | NULL
1 | 1 | init | SQL | -192856793
2 | NULL | invoice summary view | SQL | -2025099931
```
Notice the `version` column is `NULL` — that's how the history table distinguishes a repeatable
migration from a versioned one; there is no version number to have. The test then widens the view
to also sum `amount_cents`, **without touching a filename or any version number**, and starts a
second context against the same database:
```
installed_rank | version | description | type | checksum
---------------+---------+-------------------------------------------+-------+------------
-1 | NULL | << Flyway Schema History table created >> | TABLE | NULL
1 | 1 | init | SQL | -192856793
2 | NULL | invoice summary view | SQL | -2025099931
3 | NULL | invoice summary view | SQL | 750256380
```
(both tables from
[`docs/output/04-flyway-repeatable.txt`](output/04-flyway-repeatable.txt))
A brand new row — same description, new checksum — and the view really was redefined: querying
`invoice_summary` afterward returns the new `total_cents` column. Compare this against chapter
[3](03-checksum-validation.md): for a *versioned* migration, a changed checksum is a hard failure.
For a *repeatable* one, it's the trigger to rerun. Same mechanism (a checksum comparison against
the history table), opposite consequence, and the only thing that decides which applies is the
filename prefix.
<svg viewBox="0 0 700 150" xmlns="http://www.w3.org/2000/svg" font-family="monospace" font-size="13">
<rect x="20" y="20" width="200" height="50" rx="6" fill="#eef2ff" stroke="#4f46e5"/>
<text x="120" y="42" text-anchor="middle">V1__init.sql</text>
<text x="120" y="58" text-anchor="middle" font-size="11">checksum changes → FAILS</text>
<rect x="260" y="20" width="240" height="50" rx="6" fill="#dcfce7" stroke="#16a34a"/>
<text x="380" y="42" text-anchor="middle">R__invoice_summary_view.sql</text>
<text x="380" y="58" text-anchor="middle" font-size="11">checksum changes → RERUNS</text>
<text x="20" y="110" font-size="12" fill="#475569">Same comparison against flyway_schema_history's checksum column —</text>
<text x="20" y="128" font-size="12" fill="#475569">the V/R prefix alone decides whether a mismatch is a failure or a re-run.</text>
</svg>
## Going deeper
- **Repeatable migrations run last, after every pending versioned one**, in the order they appear on the classpath (alphabetically, by default) — not interleaved by when they were last changed.
- [Flyway's repeatable migration docs](https://documentation.red-gate.com/flyway/flyway-cli-and-api/concepts/migrations#repeatable-migrations) (`rel="nofollow"`) cover ordering and the `installedOn`/checksum comparison in full.
- A view is the textbook use case, but the same mechanism works for anything idempotent — a stored procedure body, or a `MERGE`/`upsert` of reference data that should reflect whatever the file currently says, not whatever it said the first time it ran.
@@ -0,0 +1,56 @@
# 6. Baselining an existing database
[← 5. Repeatable migrations](05-repeatable-migrations.md) · [Index](../README.md) · Next: [7. Why there is no undo →](07-why-there-is-no-undo.md)
Every migration tool eventually meets a database it didn't create. Someone hand-ran DDL for five
years, and now the team wants Flyway to take over from here — without dropping the schema and
replaying history that never actually happened through Flyway.
[`FlywayBaselineTest`](../src/test/java/com/ankurm/dbmigrations/flyway/FlywayBaselineTest.java)
sets this up literally: a `legacy_account` table is created with plain JDBC, with one row already
in it, *before Flyway is ever involved*. Pointing an unconfigured Flyway at that database refuses
outright — this is the same safety check from chapter [1](01-the-problem-and-mental-model.md), and
it's what makes baselining necessary rather than optional:
```
FlywayException: Found non-empty schema(s) "PUBLIC" but no schema history table. Use baseline() or set baselineOnMigrate to true to initialize the schema history table.
```
Turning on `spring.flyway.baseline-on-migrate=true` (with `baseline-version=1` and a
`baseline-description`) fixes it. `V1__init.sql` — written to describe the schema that *already
exists* — never actually runs; instead Flyway inserts a `BASELINE`-typed row claiming version 1 is
already accounted for, and only `V2__add_status_column.sql` executes for real:
```
installed_rank | version | description | type | success
---------------+---------+-------------------------------------------+----------+--------
-1 | NULL | << Flyway Schema History table created >> | TABLE | true
1 | 1 | pre-flyway schema | BASELINE | true
2 | 2 | add status column | SQL | true
```
(from [`docs/output/05-flyway-baseline.txt`](output/05-flyway-baseline.txt))
The pre-existing row survives untouched, and gains the new column:
```
ID | OWNER | STATUS
---+-----------------+-------
1 | pre-flyway-data | ACTIVE
```
<blockquote style="border-left:4px solid #ca8a04;background:#fefce8;padding:12px 16px;margin:16px 0;">
<strong>Trap:</strong> the baseline row's <code>description</code> is not a fixed string. It is
exactly whatever <code>spring.flyway.baseline-description</code> was set to — in this test,
literally <code>"pre-flyway schema"</code>. A different, fixed marker — <code>&lt;&lt; Flyway
Schema History table created &gt;&gt;</code> — belongs to a <em>different</em> row (rank
<code>-1</code>, type <code>TABLE</code>), created the moment the history table itself is created,
whether or not baselining ever happens. Confusing the two is an easy way to write a broken
assertion or a broken monitoring query — this module's own first draft did exactly that.
</blockquote>
## Going deeper
- **`baselineVersion` decides what "already accounted for" means**, and it matters which value you pick — chapter [14](14-running-both-at-once.md) shows the same mechanism used with `baseline-version=0` instead of `1`, for a database whose existing schema *doesn't* match any of your migration files at all.
- [Flyway `baseline` command reference](https://documentation.red-gate.com/flyway/flyway-cli-and-api/commands/baseline) (`rel="nofollow"`) — the one-time `flyway baseline` CLI/API call this test's `baselineOnMigrate=true` triggers automatically on first startup.
- Baselining is a one-way door in the sense that matters: once version 1 is marked as baselined, Flyway will never again check whether the schema it describes actually matches `V1__init.sql`'s content — that specific file's checksum is simply never looked at again for this database.
@@ -0,0 +1,68 @@
# 7. Why there is no undo
[← 6. Baselining an existing database](06-baselining-an-existing-database.md) · [Index](../README.md) · Next: [8. Concurrent startup and locking →](08-concurrent-startup-and-locking.md)
`Flyway` (the Community, Apache-2.0-licensed `flyway-core` artifact this module depends on) has a
public method called `undo()`. It compiles. Its Javadoc reads like every other command. Nothing
in its signature suggests it won't work — and that's exactly the trap:
[`FlywayUndoTest`](../src/test/java/com/ankurm/dbmigrations/flyway/FlywayUndoTest.java) migrates a
real database, then calls `flyway.undo()` on it, and the call throws at runtime:
```
flyway.undo() threw: org.flywaydb.core.internal.license.FlywayRedgateEditionRequiredException
message: Flyway Redgate Edition Required: undo is not supported by OSS Edition
Download Redgate Edition for free: https://rd.gt/3GGIXhh
```
(from [`docs/output/06-flyway-undo-teams-required.txt`](output/06-flyway-undo-teams-required.txt))
`undo` isn't a special case singled out for this article — it's one of a whole family of commands
that ship as compiled, callable, do-nothing stubs in the Community jar.
[`FlywayCommunityCommandSurfaceTest`](../src/test/java/com/ankurm/dbmigrations/flyway/FlywayCommunityCommandSurfaceTest.java)
doesn't take that on faith either: it opens the actual `flyway-core` jar on the test classpath at
runtime and lists every class under `org.flywaydb.core.internal.proprietaryStubs`, so this list is
read off the artifact Maven Central serves, not copied from a marketing page:
```
- auth
- check
- deploy
- diff
- difftext
- generate
- licensingconfigurationextensionstub.class
- model
- offlinepermitconfigurationextensionstub.class
- pattokenconfigurationextensionstub.class
- prepare
- undo
```
(from [`docs/output/07-flyway-proprietary-stub-commands.txt`](output/07-flyway-proprietary-stub-commands.txt))
<svg viewBox="0 0 700 160" xmlns="http://www.w3.org/2000/svg" font-family="monospace" font-size="13">
<rect x="20" y="20" width="300" height="120" rx="6" fill="#eef2ff" stroke="#4f46e5"/>
<text x="170" y="42" text-anchor="middle">flyway-core (Community, Apache-2.0)</text>
<text x="40" y="65" font-size="12">migrate() ✓ real</text>
<text x="40" y="85" font-size="12">baseline() ✓ real</text>
<text x="40" y="105" font-size="12">clean() ✓ real</text>
<text x="40" y="125" font-size="12">undo() — compiles, throws at runtime</text>
<path d="M330 125 L400 125" stroke="#334155" stroke-width="2" marker-end="url(#a4)"/>
<rect x="400" y="95" width="270" height="60" rx="6" fill="#fee2e2" stroke="#dc2626"/>
<text x="535" y="120" text-anchor="middle">FlywayRedgateEditionRequiredException</text>
<text x="535" y="138" text-anchor="middle" font-size="11">only at the moment undo() is called</text>
<defs><marker id="a4" markerWidth="8" markerHeight="8" refX="6" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#334155"/></marker></defs>
</svg>
The practical consequence: a compile-time check, a code review, or an IDE's autocomplete cannot
tell you that `undo()` needs a Redgate Teams or Enterprise license. Only calling it — or reading
the bytecode this test reads — does. For everyday rollback needs on the Community edition, the
honest options are the ones Flyway's own OSS command set actually supports: write and ship a new,
forward-only migration that reverses the change, or restore from a backup taken before the
migration ran. Both are less elegant than `flyway undo`, and both actually work on the free jar.
## Going deeper
- [Flyway editions comparison](https://www.red-gate.com/products/flyway/editions/) (`rel="nofollow"`) — the vendor's own breakdown of which commands need which tier.
- **This is not a licensing violation to work around.** The stub classes exist so a Community user who calls one of these APIs gets a clear runtime exception naming the required edition, rather than a `NoSuchMethodError` or a silent no-op — it's arguably the more honest of the two ways to gate a feature.
- Liquibase's own commercial/community split works differently — chapter [12](12-the-oss-license-service.md) verifies what actually happens on the Community jar's license-checking path, and it is not a stub-and-throw pattern at all.
@@ -0,0 +1,57 @@
# 8. Concurrent startup and locking
[← 7. Why there is no undo](07-why-there-is-no-undo.md) · [Index](../README.md) · Next: [9. Liquibase: anatomy of an update →](09-liquibase-anatomy-of-an-update.md)
A rolling deploy starts several instances of the same application at close to the same moment,
all pointed at the same database, all carrying the same migrations. If two of them both decide
"I need to run V1" at once, what stops the second from either duplicating work or corrupting the
history table?
[`FlywayConcurrentMigrateTest`](../src/test/java/com/ankurm/dbmigrations/flyway/FlywayConcurrentMigrateTest.java)
answers this empirically rather than by reading about it: it starts two Flyway instances on two
real threads, pointed at the same H2 file (opened with `AUTO_SERVER=TRUE` so genuinely separate
JDBC connections can share one file), both migrating a deliberately slow, 800ms Java-based
migration ([`V1__SlowMigration.java`](../src/test/java/com/ankurm/dbmigrations/flyway/V1__SlowMigration.java)).
```
instance A migrate() took 905ms
instance B migrate() took 911ms
wall-clock time for both, run concurrently: 915ms
```
```
flyway_schema_history:
installed_rank | version | description | success
-1 | NULL | ... | true
1 | 1 | SlowMigration| true
```
(from [`docs/output/08-flyway-concurrent-lock.txt`](output/08-flyway-concurrent-lock.txt))
The migration ran exactly once — the test asserts that directly by counting rows matching
`SlowMigration`. What's more interesting is the timing: both instances took **almost the same,
almost-full 800+ms**, and the wall clock for both together is close to *that one duration*, not
their sum. That shape is the real proof of what's happening underneath: Flyway takes a row-level
lock on its own schema history table before checking what needs to run. The winner holds it for
the whole migration; the loser blocks on that same lock for nearly the full window, then — once it
finally acquires it — finds the migration already recorded and returns almost immediately. No
external coordinator, no separate lock table: the schema history table *is* the lock.
<svg viewBox="0 0 700 170" 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="260" height="24" fill="#dcfce7" stroke="#16a34a"/>
<text x="220" y="25" text-anchor="middle" font-size="11">holds lock — runs migration (≈800ms)</text>
<text x="20" y="60">instance B</text>
<rect x="90" y="48" width="260" height="24" fill="#fee2e2" stroke="#dc2626"/>
<text x="220" y="65" text-anchor="middle" font-size="11">blocked, waiting for the same row lock</text>
<rect x="352" y="48" width="20" height="24" fill="#dcfce7" stroke="#16a34a"/>
<text x="450" y="65" font-size="11">lock acquired → nothing to do → returns</text>
<text x="20" y="110" font-size="12" fill="#475569">Both calls "took" roughly 800-900ms — the loser's time is almost entirely the wait,</text>
<text x="20" y="128" font-size="12" fill="#475569">not the (nonexistent, for it) work. Wall clock ≈ one migration's length, not the sum of both.</text>
</svg>
## Going deeper
- **A naive assertion here would compare wall-clock time against the sum of both individual durations**, expecting serialization to look like "one after the other, end to end". That's the wrong model for two threads submitted at the same instant with one blocking on the other's lock — the right check is that *both* individual durations are long, proving the loser genuinely waited rather than racing ahead. This module's test was rewritten once to fix exactly that reasoning error.
- [Flyway's locking strategy](https://documentation.red-gate.com/flyway/flyway-cli-and-api/concepts/migrations#concurrent-migration) (`rel="nofollow"`) documents the row-lock approach and which databases support it natively versus via a fallback.
- Liquibase solves the same problem with a dedicated, separate lock table rather than a row lock on the history table itself — chapter [11](11-liquibase-locking.md) measures how differently that behaves under contention.
@@ -0,0 +1,58 @@
# 9. Liquibase: anatomy of an update
[← 8. Concurrent startup and locking](08-concurrent-startup-and-locking.md) · [Index](../README.md) · Next: [10. Rollback: auto-generated vs explicit →](10-rollback-auto-generated-vs-explicit.md)
Liquibase's unit of change is a **changeset** inside a **changelog** — one YAML (or XML, or JSON,
or SQL) document listing every change in order, rather than one file per change. This module's
main changelog is
[`db.changelog-master.yaml`](../src/main/resources/db/changelog/db.changelog-master.yaml);
[`LiquibaseHappyPathTest`](../src/test/java/com/ankurm/dbmigrations/liquibase/LiquibaseHappyPathTest.java)
uses a small inline one — a `createTable` changeset, then an `insert` changeset — run through the
classic [`Liquibase`](../src/test/java/com/ankurm/dbmigrations/liquibase/LiquibaseTestSupport.java)
facade directly, with no Spring involved, so the mechanics are visible without an application
context in the way.
```
databasechangelog:
ID | AUTHOR | FILENAME | ORDEREXECUTED | EXECTYPE
-----------------+--------+-------------+---------------+---------
1-create-account | ankurm | master.yaml | 1 | EXECUTED
2-seed-account | ankurm | master.yaml | 2 | EXECUTED
account table:
ID | OWNER
---+------------------
1 | Katherine Johnson
```
(from [`docs/output/09-liquibase-happy-path.txt`](output/09-liquibase-happy-path.txt))
`DATABASECHANGELOG` is Liquibase's equivalent of `flyway_schema_history`, but the identity of a
changeset is different in kind from Flyway's version numbers: it's the triple of `id`, `author`,
and the changelog `filename` it was declared in. Two changesets with the same `id` in two
*different* files are different changesets to Liquibase; the same `id` twice in the *same* file
is a configuration error. There is no numeric ordering at all — order comes purely from position
in the changelog, top to bottom.
<svg viewBox="0 0 700 190" xmlns="http://www.w3.org/2000/svg" font-family="monospace" font-size="13">
<rect x="20" y="20" width="280" height="140" rx="6" fill="#eef2ff" stroke="#4f46e5"/>
<text x="160" y="42" text-anchor="middle">master.yaml</text>
<rect x="35" y="55" width="250" height="35" fill="#fff" stroke="#94a3b8"/>
<text x="160" y="77" text-anchor="middle" font-size="11">changeSet id: 1-create-account</text>
<rect x="35" y="100" width="250" height="35" fill="#fff" stroke="#94a3b8"/>
<text x="160" y="122" text-anchor="middle" font-size="11">changeSet id: 2-seed-account</text>
<path d="M300 90 L360 90" stroke="#334155" stroke-width="2" marker-end="url(#a5)"/>
<rect x="360" y="40" width="300" height="120" rx="6" fill="#fdf2f8" stroke="#be185d"/>
<text x="510" y="62" text-anchor="middle">DATABASECHANGELOG</text>
<text x="380" y="88" font-size="11">id=1-create-account, author=ankurm,</text>
<text x="380" y="103" font-size="11">filename=master.yaml → EXECUTED</text>
<text x="380" y="128" font-size="11">id=2-seed-account, author=ankurm,</text>
<text x="380" y="143" font-size="11">filename=master.yaml → EXECUTED</text>
<defs><marker id="a5" markerWidth="8" markerHeight="8" refX="6" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#334155"/></marker></defs>
</svg>
## Going deeper
- **`update()` and the newer command-framework path.** Liquibase 5's classic `Liquibase.update(...)` facade internally delegates to a `CommandScope`/`UpdateCommandStep` implementation rather than the older direct-execution path — visible in every stack trace in this module (chapter [11](11-liquibase-locking.md) has a full one). It doesn't change behaviour for straightforward changesets, but it's worth knowing when a stack trace looks unfamiliar next to older Liquibase tutorials.
- [Liquibase changelog structure](https://docs.liquibase.com/concepts/changelogs/home.html) (`rel="nofollow"`) — the full list of supported changelog formats and the changeset identity rules referenced above.
- Liquibase also supports **preconditions** and **contexts/labels** for conditionally running changesets — out of scope for this module, but the natural next thing to read once changesets and changelogs make sense.
@@ -0,0 +1,83 @@
# 10. Rollback: auto-generated vs explicit
[← 9. Liquibase: anatomy of an update](09-liquibase-anatomy-of-an-update.md) · [Index](../README.md) · Next: [11. Liquibase locking →](11-liquibase-locking.md)
Chapter [7](07-why-there-is-no-undo.md) showed Flyway Community has no working `undo` at all.
Liquibase's answer is `rollback()` — and whether it works depends entirely on the change type.
## When Liquibase can invert a change on its own
`createTable` is one of the change types Liquibase knows how to invert without being told how —
it just runs `DROP TABLE`.
[`LiquibaseRollbackAutoTest`](../src/test/java/com/ankurm/dbmigrations/liquibase/LiquibaseRollbackAutoTest.java)
writes no `rollback:` block anywhere in its changelog, calls `update()`, confirms the `session`
table exists, then calls `rollback(1, ...)`:
```
after update(): session table exists = true
after rollback(1): session table exists = false
```
(from [`docs/output/10-liquibase-rollback-auto.txt`](output/10-liquibase-rollback-auto.txt))
## When it can't
`insert` is not in that list.
[`LiquibaseRollbackFailTest`](../src/test/java/com/ankurm/dbmigrations/liquibase/LiquibaseRollbackFailTest.java)
applies a `createTable` changeset followed by an `insert` changeset, then calls `rollback(1, ...)`
— which only asks to roll back the *most recent* changeset, the insert. It fails for real:
```
after update(): both changesets applied
rollback(1) threw: liquibase.exception.CommandExecutionException
message: liquibase.exception.LiquibaseException: liquibase.exception.RollbackFailedException: liquibase.exception.RollbackImpossibleException: No inverse to liquibase.change.core.InsertDataChange created
```
(from [`docs/output/11-liquibase-rollback-no-inverse.txt`](output/11-liquibase-rollback-no-inverse.txt))
## The fix: write the inverse yourself
[`LiquibaseRollbackExplicitTest`](../src/test/java/com/ankurm/dbmigrations/liquibase/LiquibaseRollbackExplicitTest.java)
is the same `insert` changeset, this time carrying its own `rollback:` block:
```yaml
- insert:
tableName: audit_log
columns:
- {column: {name: event, value: 'system-start'}}
rollback:
- delete:
tableName: audit_log
where: event='system-start'
```
`rollback(1, ...)` now succeeds — the table stays, the row is gone:
```
audit_log after rollback(1) — table still exists, the row is gone:
ID | EVENT
---+------
(0 rows)
```
(from [`docs/output/12-liquibase-rollback-explicit.txt`](output/12-liquibase-rollback-explicit.txt))
<svg viewBox="0 0 700 160" xmlns="http://www.w3.org/2000/svg" font-family="monospace" font-size="13">
<rect x="20" y="20" width="200" height="50" rx="6" fill="#dcfce7" stroke="#16a34a"/>
<text x="120" y="42" text-anchor="middle">createTable</text>
<text x="120" y="58" text-anchor="middle" font-size="11">auto-invertible → DROP TABLE</text>
<rect x="250" y="20" width="200" height="50" rx="6" fill="#fee2e2" stroke="#dc2626"/>
<text x="350" y="42" text-anchor="middle">insert, no rollback:</text>
<text x="350" y="58" text-anchor="middle" font-size="11">RollbackImpossibleException</text>
<rect x="480" y="20" width="200" height="50" rx="6" fill="#dcfce7" stroke="#16a34a"/>
<text x="580" y="42" text-anchor="middle">insert + rollback:</text>
<text x="580" y="58" text-anchor="middle" font-size="11">works, runs the delete</text>
<text x="20" y="110" font-size="12" fill="#475569">Whether rollback() works is a property of the CHANGE TYPE and whether you wrote a</text>
<text x="20" y="128" font-size="12" fill="#475569">rollback: block — not a global Liquibase capability you can rely on by default.</text>
</svg>
## Going deeper
- [Which change types Liquibase can auto-generate a rollback for](https://docs.liquibase.com/workflows/liquibase-community/how-to-apply-or-revert-changes.html) (`rel="nofollow"`) — `createTable`, `addColumn`, and a handful of other structural changes; almost anything involving data (`insert`, `update`, `delete`, most `sql:` changes) needs an explicit `rollback:` block.
- **A team relying on rollback in production is really relying on discipline**: every changeset that touches data needs its rollback written and tested *at the time the changeset is written*, not discovered missing during an actual incident — `rollback(1, ...)` failing is the worst possible moment to learn `insert` has no inverse.
- Liquibase also supports `rollbackCount`, `rollbackToDate` and rolling back by tag — this module exercises only the single-changeset `rollback(int, ...)` overload.
@@ -0,0 +1,82 @@
# 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 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)
```
<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.
@@ -0,0 +1,35 @@
# 12. The OSS license service
[← 11. Liquibase locking](11-liquibase-locking.md) · [Index](../README.md) · Next: [13. The FSL license change →](13-the-fsl-license-change.md)
Chapter [7](07-why-there-is-no-undo.md) found that Flyway Community gates certain commands behind
a runtime `FlywayRedgateEditionRequiredException`. Liquibase Community 5.0 shipped something new
alongside its license change (chapter [13](13-the-fsl-license-change.md)): a `liquibase.license`
package, inside `liquibase-core` itself, that didn't exist in 4.x.
[`LiquibaseLicenseServiceTest`](../src/test/java/com/ankurm/dbmigrations/liquibase/LiquibaseLicenseServiceTest.java)
doesn't read about what that package does — it asks the actual service Spring Boot's
`LiquibaseAutoConfiguration` runs on top of, directly:
```
implementation: liquibase.license.OSSLicenseService
licenseIsInstalled(): false
licenseIsValid("any"): false
getLicenseInfo(): ""
```
(from [`docs/output/14-liquibase-oss-license-service.txt`](output/14-liquibase-oss-license-service.txt))
On the classpath this module uses — Community only, no Liquibase Pro artifact anywhere — the
license service Liquibase actually wires up is `OSSLicenseService`, which reports itself as
unlicensed and returns an empty license description. **This test found no evidence of functional
gating anywhere in the classpath this module runs on**: `update()`, `rollback()`, and every
changeset type this module exercises worked identically whether or not a license was present,
because none of them ever asked the license service anything. That's a meaningfully different
shape from Flyway's stub-and-throw pattern in chapter [7](07-why-there-is-no-undo.md) — Liquibase
Pro's paid features live in separate, additional artifacts rather than as gated stubs inside the
Community jar.
## Going deeper
- This test is a snapshot of Liquibase 5.0.3's Community classpath, not a guarantee about future releases — the license service's existence at all, brand new in 5.0, is itself evidence that Liquibase's commercial boundary is actively being redrawn (chapter [13](13-the-fsl-license-change.md) covers why).
- [Liquibase Pro feature comparison](https://www.liquibase.com/liquibase-pricing) (`rel="nofollow"`) — what Pro actually adds, as separate functionality rather than unlocked stubs.
@@ -0,0 +1,64 @@
# 13. The FSL license change
[← 12. The OSS license service](12-the-oss-license-service.md) · [Index](../README.md) · Next: [14. Running both at once →](14-running-both-at-once.md)
This is the one chapter in this module with no test behind it — it's not something you can
compile and run, it's a licensing fact worth knowing before you `mvn dependency:tree` your way
into it. It's still verified against primary sources, not paraphrased from a blog post, which is
why every claim below is a direct link.
**Liquibase Community 5.0**, the version bundled in the Spring Boot 4.1 BOM this module depends
on (5.0.3), is the first release shipped under the
[Functional Source License](https://www.liquibase.com/liquibase-functional-source-license)
(FSL-1.1-ALv2), effective September 30, 2025 — not the Apache License 2.0 every earlier Liquibase
release used, and not an OSI-approved open source license at all. FSL is "source-available": the
[Liquibase blog post announcing it](https://www.liquibase.com/blog/liquibase-community-for-the-future-fsl)
says plainly that using, modifying and self-hosting Liquibase Community stays free, but a third
party can't "take Liquibase Community and commercialize it in a way that competes with
Liquibase" — offering it as a managed service, specifically — during a two-year exclusivity
window per release. The same post confirms the FSL's built-in expiry: **two years after each
release, that release's license automatically reverts to Apache 2.0**.
<svg viewBox="0 0 700 130" xmlns="http://www.w3.org/2000/svg" font-family="monospace" font-size="13">
<rect x="20" y="20" width="200" height="50" rx="6" fill="#fef9c3" stroke="#ca8a04"/>
<text x="120" y="42" text-anchor="middle">Liquibase 5.0.3</text>
<text x="120" y="58" text-anchor="middle" font-size="11">released under FSL-1.1-ALv2</text>
<path d="M220 45 L280 45" stroke="#334155" stroke-width="2" marker-end="url(#a6)"/>
<rect x="280" y="20" width="240" height="50" rx="6" fill="#dcfce7" stroke="#16a34a"/>
<text x="400" y="42" text-anchor="middle">2 years later</text>
<text x="400" y="58" text-anchor="middle" font-size="11">reverts to Apache 2.0, automatically</text>
<text x="20" y="105" font-size="12" fill="#475569">Every future release restarts its own two-year clock — the version you pull today</text>
<text x="20" y="120" font-size="12" fill="#475569">is FSL for those two years even after a later release has already converted.</text>
<defs><marker id="a6" markerWidth="8" markerHeight="8" refX="6" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#334155"/></marker></defs>
</svg>
This is not a hypothetical concern for the projects that depend on Liquibase. The
[Apache Software Foundation's own Legal committee opened LEGAL-721](https://issues.apache.org/jira/browse/LEGAL-721)
to evaluate whether ASF projects (Apache Fineract, specifically) can keep using FSL-licensed
Liquibase 5 at all, given the ASF's policy against bundling non-Apache-compatible licenses.
Separately, [Keycloak opened issue #43391](https://github.com/keycloak/keycloak/issues/43391)
covering the same problem from the CNCF's side: the CNCF's own source-available policy — quoted
directly in that issue as "CNCF does not permit source available licenses, and an exception is
unlikely to be granted" — makes continuing to depend on FSL-licensed Liquibase a real compliance
question for a CNCF project. The options that thread lays out are exactly the ones you'd expect:
fork the last Apache-2.0-licensed 4.x release and maintain it independently, apply Liquibase only
at build time and run the generated DDL through custom tooling at runtime, or switch to a
still-fully-open-source alternative — the thread names Flyway specifically, noting that
Dependency-Track already made that switch for this reason. No final decision had been recorded in
that discussion as of this writing.
## What this means for a Spring Boot 4.1 project, concretely
Nothing changes for ordinary use: running `spring-boot-starter-liquibase` in your own application,
including in production, is exactly what the FSL permits. The restriction is aimed at competitors
reselling Liquibase itself as a hosted product, not at teams using it to manage their own schema.
The two things worth actually tracking are whether your organization's own license-compliance
process treats "source-available" the same as "open source" (many do not, by policy, regardless of
what the license permits in practice), and whether a security or compliance audit of your
dependency tree flags a non-OSI license where it previously saw Apache 2.0.
## Going deeper
- [FSL-1.1-ALv2 license text](https://fsl.software/) (`rel="nofollow"`) — the license itself, including the exact terms of the two-year Apache 2.0 conversion.
- [Liquibase GitHub issue #7382](https://github.com/liquibase/liquibase/issues/7382) (`rel="nofollow"`) — Liquibase's own tracking issue for updating "open source" language across their codebase and docs to match the new license.
- [Liquibase 4.x support policy discussion, issue #7375](https://github.com/liquibase/liquibase/issues/7375) (`rel="nofollow"`) — whether the last Apache-2.0 major version continues to receive fixes, directly relevant to the "fork 4.x" option above.
@@ -0,0 +1,93 @@
# 14. Running both at once
[← 13. The FSL license change](13-the-fsl-license-change.md) · [Index](../README.md) · Next: [15. Production checklist →](15-production-checklist.md)
Every "Flyway or Liquibase" thread eventually gets a comment asking whether you can just run
both — maybe one team owns a legacy Liquibase changelog and another is standardizing new services
on Flyway, and for a while both point at the same database.
[`BothTogetherTest`](../src/test/java/com/ankurm/dbmigrations/BothTogetherTest.java) answers it
directly: enable both starters against the same `DataSource`, with nothing else configured, and
see what actually happens on startup.
## The naive answer: it refuses to start
```
context failed to start: FlywayException: Found non-empty schema(s) "PUBLIC" but no schema history table. Use baseline() or set baselineOnMigrate to true to initialize the schema history table.
```
(from [`docs/output/15-both-together-same-datasource.txt`](output/15-both-together-same-datasource.txt))
In this configuration, Spring Boot wires Liquibase's `SpringLiquibase` bean before Flyway's
`FlywayMigrationInitializer` runs — an artifact of bean registration order here, not a documented
contract you should rely on. By the time Flyway gets its turn, Liquibase has already created a
`product` table and its own `DATABASECHANGELOG`/`DATABASECHANGELOGLOCK` tables. Flyway looks at a
non-empty schema with no `flyway_schema_history` table and does exactly what chapter
[6](06-baselining-an-existing-database.md) said it would do the first time it meets an existing
database: it refuses to guess, and startup fails.
<svg viewBox="0 0 700 170" xmlns="http://www.w3.org/2000/svg" font-family="monospace" font-size="13">
<text x="20" y="20">one DataSource</text>
<rect x="20" y="35" width="300" height="50" rx="6" fill="#eef2ff" stroke="#4f46e5"/>
<text x="170" y="55" text-anchor="middle">1. Liquibase runs first</text>
<text x="170" y="72" text-anchor="middle" font-size="11">creates PRODUCT, DATABASECHANGELOG*</text>
<path d="M320 60 L380 60" stroke="#334155" stroke-width="2" marker-end="url(#a7)"/>
<rect x="380" y="35" width="300" height="50" rx="6" fill="#fee2e2" stroke="#dc2626"/>
<text x="530" y="55" text-anchor="middle">2. Flyway runs second</text>
<text x="530" y="72" text-anchor="middle" font-size="11">sees unknown tables, no history &#8594; fails</text>
<text x="20" y="120" font-size="12" fill="#475569">Flyway's safety check that protected you in chapter 6 (a real database it didn't</text>
<text x="20" y="138" font-size="12" fill="#475569">build yet) fires here too &#8212; it can't tell "existing production schema" apart</text>
<text x="20" y="156" font-size="12" fill="#475569">from "Liquibase got here first". Same check, same message, different cause.</text>
<defs><marker id="a7" markerWidth="8" markerHeight="8" refX="6" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#334155"/></marker></defs>
</svg>
## The fix: tell Flyway nothing has run yet
Turning on `baseline-on-migrate` alone isn't enough. Its default `baseline-version` is `1`, which
tells Flyway "pretend V1 already ran" — correct only if the existing schema already matches what
V1 would have built. Here it doesn't: Liquibase's changelog built an unrelated `product` table,
not `customer`. Setting `baseline-version=0` instead tells Flyway "nothing of mine has run, apply
my whole migration set from scratch", which is what actually needs to happen alongside whatever
Liquibase already put there:
```
TABLE_NAME
---------------------
CUSTOMER
DATABASECHANGELOG
DATABASECHANGELOGLOCK
PRODUCT
flyway_schema_history
(5 rows)
-- flyway_schema_history — baseline row at 0, then V1/V2 ran for real --
version | description | type | success
--------+-------------------------------------------+----------+--------
NULL | << Flyway Schema History table created >> | TABLE | true
0 | << Flyway Baseline >> | BASELINE | true
1 | create customer | SQL | true
2 | seed customer | SQL | true
(4 rows)
-- databasechangelog — Liquibase's own bookkeeping, untouched by Flyway --
ID | AUTHOR | EXECTYPE
-----------------+--------+---------
1-create-product | ankurm | EXECUTED
2-seed-product | ankurm | EXECUTED
(2 rows)
```
(from [`docs/output/15-both-together-same-datasource.txt`](output/15-both-together-same-datasource.txt))
Both tools end up with their own tracking table, each accounting only for what it built:
`customer` under Flyway, `product` under Liquibase, and neither one aware the other exists. That's
the real shape of "running both" — not integration, just two independent bookkeepers sharing a
schema, each blind to the other's tables. You can reproduce this exact split locally:
[`application.yml`](../src/main/resources/application.yml)'s `both-naive` profile fails to start
the way the transcript above shows, and `both-fixed` starts cleanly with the baseline settings
already applied — run them with `./scripts/run.sh both-naive` and `./scripts/run.sh both-fixed`.
## Going deeper
- This module never asked Flyway or Liquibase to migrate *the same table* — the moment two tools try to own the same object, you're outside anything either one tests for, and it becomes your migration author's job to keep them apart by convention (naming, schemas, or ownership documented somewhere a new teammate will actually read).
- The bean-ordering behavior that makes Liquibase run first here is not a documented contract — treat it as incidental, not as something to design a real system around. A real migration from one tool to the other should have a clean cutover point, not a permanent "both, forever" steady state.
- [Spring Boot's own note on combining Flyway and Liquibase](https://docs.spring.io/spring-boot/reference/data/sql.html) (`rel="nofollow"`) — Boot's docs confirm both can be enabled together but don't document an execution order between them, which matches what this test observed rather than what any spec promises.
@@ -0,0 +1,90 @@
# 15. Production checklist
[← 14. Running both at once](14-running-both-at-once.md) · [Index](../README.md)
Every earlier chapter isolated one behavior. This one is the module's diagnostic exhibit and a
closing checklist built directly from what the other fourteen chapters actually found — not
generic advice, a list where every line traces back to a specific chapter.
## The diagnostic endpoint
[`MigrationDiagnosticsController`](../src/main/java/com/ankurm/dbmigrations/web/MigrationDiagnosticsController.java)
is plain JDBC against `INFORMATION_SCHEMA` plus both tools' tracking tables — no Flyway or
Liquibase Java API call anywhere in it. That's deliberate: it shows what actually landed in the
database, not what either library's in-memory model believes happened, which is exactly the gap
that caused the Liquibase filename defect in chapter [11](11-liquibase-locking.md) (Liquibase's
own summary said "successful" while the table stayed empty). Run any profile and hit
`/diag/migrations`:
```java
@GetMapping("/diag/migrations")
public Map<String, Object> migrations() {
Map<String, Object> result = new LinkedHashMap<>();
try (Connection conn = dataSource.getConnection()) {
result.put("tables", tableNames(conn));
result.put("flyway_schema_history", rows(conn,
"select \"installed_rank\", \"version\", \"description\", \"type\", \"checksum\", \"success\" "
+ "from \"flyway_schema_history\" order by \"installed_rank\"", true));
result.put("databasechangelog", rows(conn,
"select id, author, filename, orderexecuted, exectype, md5sum "
+ "from databasechangelog order by orderexecuted", true));
}
catch (SQLException ex) {
result.put("error", ex.getMessage());
}
return result;
}
```
(from [`MigrationDiagnosticsController.java`](../src/main/java/com/ankurm/dbmigrations/web/MigrationDiagnosticsController.java))
The `flyway_schema_history` query is quoted lowercase on purpose — chapter
[2](02-anatomy-of-a-migration-run.md) covers why an unquoted version of this exact query returns
nothing at all against H2's default case-folding. The controller's own javadoc says it plainly:
**delete this before a real deployment** — it has no authorization and dumps raw schema-history
rows, including checksums, to anyone who can reach the port.
<svg viewBox="0 0 700 130" xmlns="http://www.w3.org/2000/svg" font-family="monospace" font-size="13">
<rect x="20" y="20" width="200" height="50" rx="6" fill="#eef2ff" stroke="#4f46e5"/>
<text x="120" y="42" text-anchor="middle">/diag/migrations</text>
<text x="120" y="58" text-anchor="middle" font-size="11">plain JDBC, no library API</text>
<path d="M220 45 L280 45" stroke="#334155" stroke-width="2" marker-end="url(#a8)"/>
<rect x="280" y="20" width="220" height="50" rx="6" fill="#dcfce7" stroke="#16a34a"/>
<text x="390" y="42" text-anchor="middle">what actually landed</text>
<text x="390" y="58" text-anchor="middle" font-size="11">not what the library thinks happened</text>
<text x="20" y="105" font-size="12" fill="#475569">Chapter 11's phantom success is exactly the gap this endpoint is built to close &#8212;</text>
<text x="20" y="123" font-size="12" fill="#475569">a tool's own "successful" summary and the database's actual state can disagree.</text>
<defs><marker id="a8" markerWidth="8" markerHeight="8" refX="6" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#334155"/></marker></defs>
</svg>
## The checklist
Every line below is something this module actually reproduced, not a generic best practice:
1. **Never run unquoted queries against Flyway's tracking table on H2**`flyway_schema_history` is created and queried by Flyway itself using quoted lowercase identifiers, so an unquoted query folds to uppercase and finds nothing (chapter [2](02-anatomy-of-a-migration-run.md)).
2. **A checksum mismatch fails startup, full stop** — editing an already-applied migration file is caught the instant the app starts, not silently ignored (chapter [3](03-checksum-validation.md)).
3. **Out-of-order migrations fail the whole startup by default**, not just the late migration — set `outOfOrder=true` deliberately if parallel branches will ever land migrations non-sequentially (chapter [4](04-out-of-order-migrations.md)).
4. **`baselineVersion` defaults to 1**, meaning "assume the existing schema already matches V1" — correct only when that's literally true. Get it wrong and Flyway either re-runs migrations that already happened or skips ones that never did (chapter [6](06-baselining-an-existing-database.md), chapter [14](14-running-both-at-once.md)).
5. **Flyway Community's `undo` is a stub that throws `FlywayRedgateEditionRequiredException` at runtime** despite compiling fine — there is no working "undo" without a Redgate license (chapter [7](07-why-there-is-no-undo.md)).
6. **Liquibase can only auto-generate a rollback for structural changes** like `createTable` — anything touching data (`insert`, `update`, `delete`) needs an explicit `rollback:` block written and tested when the changeset is written, not discovered missing during an incident (chapter [10](10-rollback-auto-generated-vs-explicit.md)).
7. **Liquibase's default lock-poll rate is 10 seconds** — a losing instance in a rolling deploy can sit idle for up to ten seconds waiting on sub-second work, which matters if a readiness probe times out sooner (chapter [11](11-liquibase-locking.md)).
8. **Liquibase Community 5.0 ships under the FSL, not Apache 2.0** — ordinary production use is unaffected, but license-compliance tooling that treats "source-available" differently from "open source" will flag it (chapter [13](13-the-fsl-license-change.md)).
9. **Running both tools against one database means two independent bookkeepers, not integration** — each is blind to tables the other owns, and the moment they'd need to touch the same object you're outside anything either one tests for (chapter [14](14-running-both-at-once.md)).
10. **Delete the diagnostics endpoint before shipping** — it has no authorization and exists purely so this module's own claims could be checked against a live server.
## Should you even do this — pick one, deliberately
Neither tool is "safer" in the abstract; they fail differently, and this module reproduced both
failure shapes directly. Flyway's version-ordered, checksum-validated model is easy to reason
about and its Community edition simply has no rollback — plan every migration as forward-only from
day one. Liquibase's changeset model supports real rollbacks, but only for the change types it
knows how to invert, and its 5.0 license change is a real (if narrow) compliance question some
organizations will need to route through their own process. Running both together is a workable
bridge during a migration between them, never a permanent architecture — pick one, own it, and use
this module to see exactly what "own it" has to account for.
## Going deeper
- [Flyway configuration reference](https://documentation.red-gate.com/fd/configuration-184127302.html) (`rel="nofollow"`) — every property this module exercised, plus the ones it didn't.
- [Liquibase configuration reference](https://docs.liquibase.com/parameters/home.html) (`rel="nofollow"`) — including the lock-related settings from chapter [11](11-liquibase-locking.md).
- The module's own [`README.md`](../README.md) indexes every chapter, every test, and every captured transcript this checklist draws on.
@@ -0,0 +1,20 @@
========================================================
Flyway: two versioned migrations, applied on startup
========================================================
captured: 2026-09-15T07:07:26.946024742Z
-- flyway_schema_history --
installed_rank | version | description | type | checksum | success
---------------+---------+-------------------------------------------+-------+-------------+--------
-1 | NULL | << Flyway Schema History table created >> | TABLE | NULL | true
1 | 1 | create customer | SQL | 1461549807 | true
2 | 2 | seed customer | SQL | -1214875726 | true
(3 rows)
-- customer table --
ID | NAME | EMAIL
---+--------------+------------------
1 | Ada Lovelace | [email protected]
2 | Grace Hopper | [email protected]
(2 rows)
@@ -0,0 +1,16 @@
=====================================================
Flyway: editing an already-applied migration file
=====================================================
captured: 2026-09-15T07:07:27.074690371Z
-- first startup — V1 applied as originally written --
context started cleanly, V1__init.sql applied
-- second startup — V1__init.sql edited after being applied --
FlywayValidateException: Validate failed: Migrations have failed validation
Migration checksum mismatch for migration version 1
-> Applied to database : -712784830
-> Resolved locally : 756220147
Either revert the changes to the migration, or run repair to update the schema history.
Need more flexibility with validation rules? Learn more: https://help.red-gate.com/help/flyway-cli12/help_4.aspx?topic=flyway-blog/older-posts/customize-validation-rules-with-ignoremigrationpatterns
@@ -0,0 +1,29 @@
==============================================================================
Flyway: a lower-versioned migration lands after a higher one is already applied
==============================================================================
captured: 2026-09-15T07:07:28.552801366Z
-- first startup — V1 and V3 only --
applied: V1, V3
-- second startup — outOfOrder=false (Flyway default) --
context failed to start: FlywayValidateException: Validate failed: Migrations have failed validation
Detected resolved migration not applied to database: 2.
To ignore this migration, set -ignoreMigrationPatterns='*:ignored'. To allow executing this migration, set -outOfOrder=true.
Need more flexibility with validation rules? Learn more: https://help.red-gate.com/help/flyway-cli12/help_4.aspx?topic=flyway-blog/older-posts/customize-validation-rules-with-ignoremigrationpatterns
installed_rank | version | description | success
---------------+---------+-------------------------------------------+--------
-1 | NULL | << Flyway Schema History table created >> | true
1 | 1 | init | true
2 | 3 | add note index | true
(3 rows)
-- third startup — outOfOrder=true --
installed_rank | version | description | success
---------------+---------+-------------------------------------------+--------
-1 | NULL | << Flyway Schema History table created >> | true
1 | 1 | init | true
2 | 3 | add note index | true
3 | 2 | add note length check | true
(4 rows)
@@ -0,0 +1,36 @@
==================================================================
Flyway: a repeatable migration reruns on checksum change alone
==================================================================
captured: 2026-09-15T07:07:28.316653172Z
-- first startup --
installed_rank | version | description | type | checksum
---------------+---------+-------------------------------------------+-------+------------
-1 | NULL | << Flyway Schema History table created >> | TABLE | NULL
1 | 1 | init | SQL | -192856793
2 | NULL | invoice summary view | SQL | -2025099931
(3 rows)
-- invoice_summary right after creation --
STATUS | CNT
--------+----
PAID | 1
PENDING | 1
(2 rows)
-- second startup — only R__invoice_summary_view.sql changed --
installed_rank | version | description | type | checksum
---------------+---------+-------------------------------------------+-------+------------
-1 | NULL | << Flyway Schema History table created >> | TABLE | NULL
1 | 1 | init | SQL | -192856793
2 | NULL | invoice summary view | SQL | -2025099931
3 | NULL | invoice summary view | SQL | 750256380
(4 rows)
-- invoice_summary after the repeatable migration reran --
STATUS | CNT | TOTAL_CENTS
--------+-----+------------
PAID | 1 | 5000
PENDING | 1 | 3000
(2 rows)
@@ -0,0 +1,22 @@
==================================================
Flyway: adopting an existing, unmanaged schema
==================================================
captured: 2026-09-15T07:07:29.126413637Z
-- migrate() with no baseline configuration, against a non-empty schema --
FlywayException: Found non-empty schema(s) "PUBLIC" but no schema history table. Use baseline() or set baselineOnMigrate to true to initialize the schema history table.
-- migrate() with baselineOnMigrate=true, baselineVersion=1 --
installed_rank | version | description | type | success
---------------+---------+-------------------------------------------+----------+--------
-1 | NULL | << Flyway Schema History table created >> | TABLE | true
1 | 1 | pre-flyway schema | BASELINE | true
2 | 2 | add status column | SQL | true
(3 rows)
-- legacy_account keeps its pre-existing row and gains the new column --
ID | OWNER | STATUS
---+-----------------+-------
1 | pre-flyway-data | ACTIVE
(1 row)
@@ -0,0 +1,8 @@
==============================================================
Flyway: calling the public undo() API on the Community jar
==============================================================
captured: 2026-09-15T07:07:29.409916017Z
flyway.undo() threw: org.flywaydb.core.internal.license.FlywayRedgateEditionRequiredException
message: Flyway Redgate Edition Required: undo is not supported by OSS Edition
Download Redgate Edition for free: https://rd.gt/3GGIXhh
@@ -0,0 +1,17 @@
==============================================================================
Flyway: every command name that resolves to a Redgate-edition-required stub in flyway-core
==============================================================================
captured: 2026-09-15T07:07:26.936295299Z
- auth
- check
- deploy
- diff
- difftext
- generate
- licensingconfigurationextensionstub.class
- model
- offlinepermitconfigurationextensionstub.class
- pattokenconfigurationextensionstub.class
- prepare
- undo
@@ -0,0 +1,15 @@
==============================================================
Flyway: two instances calling migrate() at the same moment
==============================================================
captured: 2026-09-15T07:07:28.249758300Z
instance A migrate() took 932ms
instance B migrate() took 910ms
wall-clock time for both, run concurrently: 937ms
-- flyway_schema_history after both finished --
installed_rank | version | description | success
---------------+---------+-------------------------------------------+--------
-1 | NULL | << Flyway Schema History table created >> | true
1 | 1 | SlowMigration | true
(2 rows)
@@ -0,0 +1,18 @@
=================================================
Liquibase: two changesets, applied on startup
=================================================
captured: 2026-09-15T07:07:15.896321149Z
-- databasechangelog --
ID | AUTHOR | FILENAME | ORDEREXECUTED | EXECTYPE
-----------------+--------+-------------+---------------+---------
1-create-account | ankurm | master.yaml | 1 | EXECUTED
2-seed-account | ankurm | master.yaml | 2 | EXECUTED
(2 rows)
-- account table --
ID | OWNER
---+------------------
1 | Katherine Johnson
(1 row)
@@ -0,0 +1,12 @@
======================================================
Liquibase: auto-generated rollback for createTable
======================================================
captured: 2026-09-15T07:07:16.057288822Z
after update(): session table exists = true
after rollback(1): session table exists = false
-- databasechangelog after rollback --
ID | EXECTYPE
---+---------
(0 rows)
@@ -0,0 +1,8 @@
========================================================================
Liquibase: rolling back an insert changeset with no <rollback> block
========================================================================
captured: 2026-09-15T07:07:26.800580455Z
after update(): both changesets applied
rollback(1) threw: liquibase.exception.CommandExecutionException
message: liquibase.exception.LiquibaseException: liquibase.exception.RollbackFailedException: liquibase.exception.RollbackImpossibleException: No inverse to liquibase.change.core.InsertDataChange created
@@ -0,0 +1,16 @@
===================================================================
Liquibase: the same insert, now with an explicit rollback block
===================================================================
captured: 2026-09-15T07:07:16.370040970Z
-- audit_log right after update() --
ID | EVENT
---+-------------
1 | system-start
(1 row)
-- audit_log after rollback(1) — table still exists, the row is gone --
ID | EVENT
---+------
(0 rows)
@@ -0,0 +1,20 @@
================================================================
Liquibase: two instances calling update() at the same moment
================================================================
captured: 2026-09-15T07:07:26.705434740Z
instance A update() took 874ms
instance B update() took 10097ms
wall-clock time for both, run concurrently: 10099ms
-- databasechangeloglock after both finished --
ID | LOCKED | LOCKEDBY
---+--------+---------
1 | false | NULL
(1 row)
-- databasechangelog after both finished --
ID | AUTHOR | EXECTYPE
--------------+--------+---------
1-slow-change | ankurm | EXECUTED
(1 row)
@@ -0,0 +1,9 @@
==============================================================================
Liquibase: the LicenseService actually wired up on the classpath Boot uses
==============================================================================
captured: 2026-09-15T07:07:16.051670223Z
implementation: liquibase.license.OSSLicenseService
licenseIsInstalled(): false
licenseIsValid("any"): false
getLicenseInfo(): ""
@@ -0,0 +1,34 @@
=======================================================================
Flyway and Liquibase, both enabled: the naive failure, then the fix
=======================================================================
captured: 2026-09-15T07:07:11.366831858Z
-- naive: both enabled, no other configuration --
context failed to start: FlywayException: Found non-empty schema(s) "PUBLIC" but no schema history table. Use baseline() or set baselineOnMigrate to true to initialize the schema history table.
-- fixed: spring.flyway.baseline-on-migrate=true, spring.flyway.baseline-version=0 --
TABLE_NAME
---------------------
CUSTOMER
DATABASECHANGELOG
DATABASECHANGELOGLOCK
PRODUCT
flyway_schema_history
(5 rows)
-- flyway_schema_history — baseline row at 0, then V1/V2 ran for real --
version | description | type | success
--------+-------------------------------------------+----------+--------
NULL | << Flyway Schema History table created >> | TABLE | true
0 | << Flyway Baseline >> | BASELINE | true
1 | create customer | SQL | true
2 | seed customer | SQL | true
(4 rows)
-- databasechangelog — Liquibase's own bookkeeping, untouched by Flyway --
ID | AUTHOR | EXECTYPE
-----------------+--------+---------
1-create-product | ankurm | EXECUTED
2-seed-product | ankurm | EXECUTED
(2 rows)
+72
View File
@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>db-migrations-flyway-liquibase</artifactId>
<version>1.0.0</version>
<name>db-migrations-flyway-liquibase</name>
<description>Companion code for Flyway vs Liquibase on Spring Boot 4</description>
<properties>
<java.version>25</java.version>
<maven.compiler.release>25</maven.compiler.release>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-flyway</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-liquibase</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-flyway-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-liquibase-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
# Regenerate every transcript under docs/output/. This module's tests ARE the transcripts —
# each one asserts the same numbers it writes via the Transcript helper — so a single `mvn test`
# is the whole regeneration step; there is no separate demo script to drive.
set -euo pipefail
cd "$(dirname "$0")/.."
mvn -q test
echo "Regenerated: $(ls docs/output | wc -l) files in docs/output/"
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Runs the demo app with an optional Spring profile, so you can watch each scenario for real
# instead of only reading about it. See the README's "Try it yourself" table for what each does.
#
# ./scripts/run.sh quickstart — Flyway only, clean startup
# ./scripts/run.sh both-naive both tools enabled, no baseline — fails to start on purpose
# ./scripts/run.sh both-fixed both tools enabled, baseline-on-migrate + baseline-version=0
#
# Once it's up: curl http://localhost:8080/diag/migrations
set -euo pipefail
cd "$(dirname "$0")/.."
PROFILE="${1:-}"
if [ -n "$PROFILE" ]; then
mvn -q spring-boot:run -Dspring-boot.run.profiles="$PROFILE"
else
mvn -q spring-boot:run
fi
@@ -0,0 +1,22 @@
package com.ankurm.dbmigrations;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* Runnable demo used only for the {@code /diag/migrations} endpoint (see
* {@link com.ankurm.dbmigrations.web.MigrationDiagnosticsController} and
* docs/15-production-checklist.md). Every other scenario in this module runs as a JUnit test
* against {@code ApplicationContextRunner} so it starts in milliseconds and needs no server —
* see docs/01-the-problem-and-mental-model.md for why that split was made.
*
* <p>Delete the diagnostics endpoint before shipping a real service; it prints raw migration
* bookkeeping tables with no authorization check.
*/
@SpringBootApplication
public class DbMigrationsApplication {
public static void main(String[] args) {
SpringApplication.run(DbMigrationsApplication.class, args);
}
}
@@ -0,0 +1,87 @@
package com.ankurm.dbmigrations.web;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import javax.sql.DataSource;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Prints both tools' own bookkeeping tables side by side against whatever the classpath and
* {@code spring.flyway.enabled} / {@code spring.liquibase.enabled} left on the schema. There is
* no Flyway or Liquibase Java API call in here at all — it is plain JDBC against
* {@code INFORMATION_SCHEMA} plus the two tracking tables, which is exactly what makes it honest:
* it shows what actually landed in the database, not what either library's in-memory model
* believes happened.
*
* <p>Documented in docs/15-production-checklist.md. Delete this before a real deployment —
* it has no authorization and dumps raw schema-history rows.
*/
@RestController
public class MigrationDiagnosticsController {
private final DataSource dataSource;
public MigrationDiagnosticsController(DataSource dataSource) {
this.dataSource = dataSource;
}
@GetMapping("/diag/migrations")
public Map<String, Object> migrations() {
Map<String, Object> result = new LinkedHashMap<>();
try (Connection conn = dataSource.getConnection()) {
result.put("tables", tableNames(conn));
// Flyway creates and queries its own table using quoted lowercase identifiers, so an
// unquoted (and therefore upper-cased, by H2's default folding) query against it would
// find nothing at all — see docs/02-anatomy-of-a-migration-run.md.
result.put("flyway_schema_history", rows(conn,
"select \"installed_rank\", \"version\", \"description\", \"type\", \"checksum\", \"success\" "
+ "from \"flyway_schema_history\" order by \"installed_rank\"", true));
result.put("databasechangelog", rows(conn,
"select id, author, filename, orderexecuted, exectype, md5sum "
+ "from databasechangelog order by orderexecuted", true));
}
catch (SQLException ex) {
result.put("error", ex.getMessage());
}
return result;
}
private List<String> tableNames(Connection conn) throws SQLException {
List<String> names = new ArrayList<>();
try (ResultSet rs = conn.getMetaData().getTables(null, "PUBLIC", "%", new String[] { "TABLE" })) {
while (rs.next()) {
names.add(rs.getString("TABLE_NAME"));
}
}
return names;
}
private Object rows(Connection conn, String sql, boolean tolerateMissingTable) throws SQLException {
List<Map<String, Object>> out = new ArrayList<>();
try (var st = conn.createStatement(); ResultSet rs = st.executeQuery(sql)) {
int cols = rs.getMetaData().getColumnCount();
while (rs.next()) {
Map<String, Object> row = new LinkedHashMap<>();
for (int i = 1; i <= cols; i++) {
row.put(rs.getMetaData().getColumnLabel(i).toLowerCase(), rs.getObject(i));
}
out.add(row);
}
}
catch (SQLException ex) {
if (tolerateMissingTable) {
return "not present: " + ex.getMessage();
}
throw ex;
}
return out;
}
}
@@ -0,0 +1,47 @@
spring:
application:
name: db-migrations-flyway-liquibase
datasource:
url: jdbc:h2:file:./data/quickstart;AUTO_SERVER=TRUE
username: sa
password: ""
flyway:
enabled: true
liquibase:
enabled: false
change-log: classpath:db/changelog/db.changelog-master.yaml
management:
endpoints:
web:
exposure:
include: flyway,liquibase,health
---
# ./scripts/run.sh both-naive — both tools enabled against the same fresh database, no other
# configuration. Fails to start: Liquibase's SpringLiquibase bean runs before Flyway's, so by the
# time Flyway checks the schema it finds tables it doesn't recognize. See docs/14-running-both-at-once.md.
spring:
config:
activate:
on-profile: both-naive
datasource:
url: jdbc:h2:file:./data/both-naive;AUTO_SERVER=TRUE
liquibase:
enabled: true
---
# ./scripts/run.sh both-fixed — the fix from docs/14-running-both-at-once.md applied: Flyway is
# told to baseline the schema Liquibase already built, starting from "nothing has run yet"
# (baseline-version: 0) rather than the default "version 1 already ran" baseline.
spring:
config:
activate:
on-profile: both-fixed
datasource:
url: jdbc:h2:file:./data/both-fixed;AUTO_SERVER=TRUE
liquibase:
enabled: true
flyway:
baseline-on-migrate: true
baseline-version: 0
@@ -0,0 +1,38 @@
databaseChangeLog:
- changeSet:
id: 1-create-product
author: ankurm
changes:
- createTable:
tableName: product
columns:
- column:
name: id
type: bigint
autoIncrement: true
constraints:
primaryKey: true
- column:
name: sku
type: varchar(64)
constraints:
nullable: false
unique: true
- column:
name: price_cents
type: bigint
constraints:
nullable: false
- changeSet:
id: 2-seed-product
author: ankurm
changes:
- insert:
tableName: product
columns:
- column:
name: sku
value: WIDGET-1
- column:
name: price_cents
valueNumeric: 1999
@@ -0,0 +1,5 @@
create table customer (
id bigint generated by default as identity primary key,
name varchar(120) not null,
email varchar(200) not null unique
);
@@ -0,0 +1,2 @@
insert into customer (name, email) values ('Ada Lovelace', '[email protected]');
insert into customer (name, email) values ('Grace Hopper', '[email protected]');
@@ -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);
}
}
}
@@ -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();
}
}
@@ -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();
}
}
@@ -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;
}
}
@@ -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();
}
}
@@ -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();
}
}
@@ -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();
}
}
@@ -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();
}
}
@@ -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");
}
}
@@ -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);
}
}
@@ -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();
}
}