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:
@@ -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><< Flyway
|
||||
Schema History table created >></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 → 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 — 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 —</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)
|
||||
Reference in New Issue
Block a user