ADR-125: Run migrations with goose from one v0.8.1 baseline
Date: 2026-10-18
Status: Accepted (2026-10-18). The cut-over is built; it ships in v0.9.0. Amended 2026-10-22 (#1354) and 2026-10-28 (#1409): the takeover of a v0.8.x database and the upgrade from a build of main are dropped. v0.9.0 installs fresh only and refuses an older database. The text below about taking over a v0.8.x database is history.
Supersedes: ADR-124, which was never built
Amends: ADR-073 (decision 3, the migration runner, is replaced) and ADR-107 (migration 0009’s rollback stops shipping)
Summary
Section titled “Summary”From v0.9.0, Hardcount applies database migrations with goose
(github.com/pressly/goose/v3 v3.27.0) instead of golang-migrate and its
own runner. The 178 old migration files become one generated baseline at
version 180, where the old files end. An empty database runs that
baseline. A database from v0.8.0 or v0.8.1 is taken over with no table
change. A failed migration leaves nothing to repair, so the dirty state
and hardcount migrate force go away, and several copies may migrate as they
start. We switch now because Hardcount has no customer installs yet: the
first partners start on goose and never need an adopt step.
Context
Section titled “Context”A migration is a versioned SQL file that changes the database schema.
Hardcount has 178 of them (0001 to 0180; 0143 and 0166 do not exist), applied
by golang-migrate v4.19.1. golang-migrate keeps one row in
schema_migrations: a version and a dirty flag. When a file fails, the
flag stays set, and Hardcount refuses to start until someone runs
hardcount migrate force.
CREATE INDEX CONCURRENTLY builds an index without blocking writes, but
it cannot run inside a transaction. Three files use it (0062, 0112, 0130).
For them, Hardcount wraps the library in its own “hybrid” runner (ADR-073).
With the repair and lock-timeout code added since (#967, #987), the
package holds about 1,535 lines of code and 1,694 lines of tests.
ADR-124
(2026-10-17) chose to delete the runner and run plain golang-migrate. It
kept the dirty flag and force. It also made “one process migrates at a
time” a rule for operators, because golang-migrate’s lock can deadlock
against an index build (E3 below). It rejected goose mainly because every
install would have to change its version table, with an adopt step.
Three facts change that weighing:
- No customer runs Hardcount yet. The adopt step costs nothing today, and it costs more with every install. If goose ships before the first partner installs, partners start on goose and never adopt.
- golang-migrate has not fixed its lock.
Issue #960,
“Postgres deadlocking when multiple processes run
CREATE INDEX CONCURRENTLY”, has been open since 2023-07-25. Both fix PRs, #962 (2023-07-26) and #1451 (2026-10-03), are open and unmerged (checked 2026-10-18). So ADR-124’s one-migrator rule would stay a rule with no code behind it. - The baseline’s open questions have answers. ADR-124 noted that a baseline needs edits to pg_dump’s output and was proven only as a superuser. Here a script generates it from the published v0.8.1 image, and a CI job proves on every run that a new install and an upgraded install are identical, under three role setups.
The experiments ran on 2026-10-17 on Postgres 16, in CI only, on a branch that was never merged (CI run 39047170196). An INVALID index is one Postgres keeps but never uses.
| Experiment | Result |
|---|---|
| E3 | golang-migrate’s lock blocks (pg_advisory_lock). A second migrator that waits on it during a CONCURRENTLY build creates a deadlock. Postgres cancels one side (40P01). When it cancels the build, the index is INVALID and the version dirty. A lock that polls (pg_try_advisory_lock) showed no deadlock. |
| E4, phase 1 | Two goose processes, with an older transaction open. One ran a no-transaction index file, every statement on one connection, with lock_timeout going 2s, 0, 0, 2s. The other polled the lock, then found nothing to do. One version row; the index is valid. |
| E4, phase 2 | A normal file stopped at its lock_timeout (55P03) after 2 seconds. goose wrote no version row, and a rerun needed no repair. |
| E4, phase 3 | A no-transaction build failed on a duplicate key (23505) and left an INVALID index. It also left lock_timeout = 0 on its connection, because RESET never ran. After the data was fixed, a rerun that drops the index first built it valid. |
| E5 | A failed goose file leaves no table and no version row. The same file under golang-migrate leaves a dirty version that needs force. |
| E6 | A baseline made with pg_dump from the 178 files, applied by goose, gives the same schema and data as the 178 files, compared both ways. It needed edits to pg_dump’s output and the leader_leases seed, and ran only as a superuser. |
Decision
Section titled “Decision”-
goose runs migrations. Hardcount uses goose’s
ProviderAPI at v3.27.0, with the global registry off.migrate.Up(dsn)keeps its signature, so its four callers do not change. These are deleted:- golang-migrate, the hybrid runner and the lock-timeout clean-up;
DirtyErrorandhardcount migrate force(a stub stays for one release and exits with code 2 and a pointer to v0.8.1’s runbook);cmd/hardcount-migrate-safety, which steps through the old files;- the 178 old files. They stay in git at tag
v0.8.1and in the v0.8.1 image.
-
One baseline at version 180.
scripts/gen-baseline.shwritesinternal/platform/migrate/baseline/00180_baseline.sql. It runs the published v0.8.1 image, pinned by digest, on an empty Postgres 16. It checks the result (version 180, not dirty; only the fiveleader_leasesrows), then runspg_dump --schema-only. Nobody edits the output by hand. The script makes a fixed set of edits, and a guard stops it on anything unexpected:- It drops psql meta-commands (
\restrict) and theDumped bylines. - It turns each header
SETintoSET LOCAL, and makessearch_pathtransaction-local. goose runs every file of oneUpon the same connection, so a plainSETwould leak into the next file. - It drops
COMMENT ON EXTENSION. Only an extension’s owner may run it, and managed Postgres creates the extensions itself. - It appends migration 0174’s
leader_leasesseed as written, so each token comes fromclock_timestamp()at install time. - It appends migration 0150’s
REVOKE UPDATE, DELETE, TRUNCATE ON public.audit_log FROM hardcount_app. Wherehardcount_apphas default privileges (Compose, the managed setup), the dump alone would lose that revoke.
New files start at
00181, namedNNNNN_name.sql. Version numbers do not change across the switch. - It drops psql meta-commands (
-
Migration 180 builds or adopts. It is a goose Go migration, so it runs inside goose’s lock and transaction, and goose writes row 180 in the same transaction:
- With no
schema_migrations, it runs the baseline. - With a v0.8 record at (180, not dirty), it changes no table. It
marks
schema_migrationsas frozen with a table comment. - With any other record, it refuses.
goose is the only writer of
public.goose_db_version. The name is schema-qualified, so the baseline’s emptysearch_pathcannot hide it.schema_migrationsstays, frozen: if it were dropped, a v0.8.x binary would replay 0001 on a full database. Migration 180’s Down refuses and says to restore the backup taken before the upgrade. - With no
-
A read-only state check runs first. It runs before every
Up, in the boot check (CheckSchemaReady) and inhardcount migrate status. It reads both version records, takes no lock and creates no table. It refuses three states. Each message states the values it found, ends “Nothing was changed.”, and names the next step.State found What the message says A v0.8 record that is not a clean 180: older, dirty, or from a development build Upgrade with v0.8.1 first, or use an empty database goose has 180, and schema_migrationsis unmarked or not at (180, not dirty)The records disagree. A v0.8.x backup was restored over this database, or a v0.8.x binary ran migrate,rollbackorforce. Follow the runbookHardcount’s tables resolve outside the publicschemaRestore the database so its tables are in publicThe table comment is what tells a frozen record from one that a v0.8.x backup or binary brought back.
-
Locks and connections.
- goose’s session lock polls
pg_try_advisory_lock(lock id 4097083626) every 5 seconds, for up to 30 minutes, the same total wait as v0.8.1. When the wait runs out, Hardcount returns a typed error that points athardcount migrate status. - Each command opens its own pool of one connection and closes it on
return.
MIGRATION_LOCK_TIMEOUT(default 5 seconds) stays and setslock_timeouton that connection. Closing the pool matters, because a failed index file leaveslock_timeout = 0on its session (E4, phase 3). - The session goose migrates on has
search_path = public. The baseline names every objectpublic.x, so a later file’sCREATE TABLE xmust land inpublictoo, not in a schema the admin user’s ownsearch_pathlists first. The state check reads before that, with the operator’ssearch_path, so it still finds a v0.8 install outsidepublic. The three extensions must be inpublictoo.
- goose’s session lock polls
-
Rules for new files, checked by
-shortlints:-
A normal file runs in one goose transaction. It has no transaction control and uses only
SET LOCAL. A multi-line$$body goes between-- +goose StatementBeginand-- +goose StatementEnd. -
An index build is the only file allowed to run without a transaction, and it has one fixed shape:
-- +goose NO TRANSACTION-- +goose UpSET lock_timeout = 0;DROP INDEX CONCURRENTLY IF EXISTS idx_x;CREATE [UNIQUE] INDEX CONCURRENTLY idx_x ON t (c);RESET lock_timeout;-- +goose DownSET lock_timeout = 0;DROP INDEX CONCURRENTLY IF EXISTS idx_x;RESET lock_timeout;One index per file, with a new name and no
IF NOT EXISTS. goose writes the version row after a no-transaction file. A crash between the build and that row leaves a valid index with no row, so the file drops the index first, and every rerun is safe.IF NOT EXISTSstays banned because it hides an INVALID index (ADR-124, E2).
-
-
Several copies. Copies may migrate as they start (
RUN_MIGRATIONS_ON_BOOT=true). They take turns on the polling lock and cannot deadlock against an index build (E3, E4). Old code on a new schema is still not covered, so for a release with migrations, the old copies stop before the migration runs. From v0.8.x, runhardcount migratewith v0.9.0 once. It records 180 and changes no table, so the v0.8.x copies keep serving while it runs. Then upgrade the copies one at a time. -
CLI.
hardcount migrate statusshows the plan: the database’s version, this release’s version, the state, the files still to apply, and whether a migration is running now. It reuses the output from #1171, and it is read-only.hardcount migrate rollbackruns only whenAPP_ENVislocal, one step at a time, and never below 180. Anywhere else it refuses: going back means restoring the backup taken before the upgrade.
-
Proof in CI. A new
migrationsjob runs on every PR:- A new install of HEAD must equal an upgraded install (the v0.8.1 image, then HEAD twice). It compares the schema with owners and access rights, the data, and the applied versions.
- It runs under three setups: Compose (with
postgres-init.sh), managed (a migrating role that is not a superuser, extensions made in advance) and the CI test database. - A restore drill backs up a v0.8.1 database, adopts it, and restores
the backup over it. The next
hardcount migratemust refuse and change nothing. - HEAD applies on Postgres 14. If it fails, the docs stop calling 14 the oldest version that should work.
-
Rollout. Four PRs, in order:
- This ADR.
- The cut-over: code, baseline, tests, the
migrationsjob and the operator docs. No golang-migrate file may merge before it. - The lints, a
make migrationscaffold anddocs/dev/migrations.md. It merges before the first00181file: until then a-shorttest fails on any file insql/, and PR 3 deletes that test. - Release v0.9.0, after an upgrade drill from v0.8.1 and a cold install.
Why this design
Section titled “Why this design”- It removes what ADR-124 left to people. A failed file leaves
nothing to repair (E4 phase 2, E5), so there is no dirty flag and no
forcetarget to choose. goose’s polling lock makes boot-time migration from several copies safe in code (E3, E4 phase 1), not by an operator rule. - This is the cheapest time. ADR-124 kept golang-migrate partly
because the switch cannot be undone for installs. With no customer
installs, that one-way cost falls on almost no one. A v0.8.x install
adopts with no table change. Rigor follows reversibility
(
feedback_design_principles_simple_correct_standard), and this step is cheapest to take before anyone depends on the old shape. - A maintained library does the hard parts
(
feedback_prefer_battle_tested_libs). goose has a per-file no-transaction mode and a polling lock, and it writes a normal file’s version row in that file’s transaction. Hardcount keeps about 350 lines: the state check, migration 180, the status output and the error messages. - One writer and one lock. Adoption is goose’s own migration 180, so goose’s lock, transaction and version write cover both adoption and the baseline. There is no second writer of the version table and no rule about what may run first.
- Version numbers keep their meaning. A database recorded at 180 stays at 180. Comments and tests that cite a migration number, such as “migration 0150”, still mean one thing.
- The proof runs on every PR, not once. The baseline’s edits cannot
drift, because the
migrationsjob compares a new install with an upgraded one each time. - Almost no other module moves. goose v3.27.0 raises one module in
Hardcount’s Linux build:
prometheus/procfs, 0.16.1 to 0.19.2, which the Prometheus client uses to read process metrics from/proc(the first count was taken on macOS, where that module is not built; corrected 2026-10-18 in the cut-over PR). Its provider, lock and Postgres code is the same as in v3.29.0.
Alternatives considered
Section titled “Alternatives considered”- A. Plain golang-migrate (ADR-124). It deletes the runner, and each
new file sets its own lock limit. Rejected: it keeps the dirty flag and
force, and the one-migrator rule has no code behind it while golang-migrate#960 stays open. The cost of switching later grows with every install. - B. A baseline at version 1, as goose projects usually start. Rejected: a v0.8 database at 180 and a new one at 1 would describe the same schema with different numbers. Every reference to a migration number would need care.
- C. A separate adopt step (a “bridge”) before goose runs. Rejected:
it is a second writer of
goose_db_versionwith its own lock, and it needs a rule that no goose call may run before it. Migration 180 does the same work inside goose. - D. goose v3.29.0, the latest release. Rejected for now: it raises
18 modules in Hardcount’s build, including pgx (5.9.2 to 5.11.0) and
shopspring/decimal (1.4.0 to 1.5.0), the money type. Releases 3.27.1
to 3.29.0 add dependency upgrades, a
goose createfix and features for other databases; their Postgres code is unchanged. A later bump is an ordinary dependency PR. - E. Drop
schema_migrationsat adoption. Rejected: a v0.8.x binary would then see no record and replay 0001 on a full database. - F. Drop
goose_db_versioninscripts/restore.sh. Rejected: it covers only that script, and the drop cannot joinpg_restore’s single transaction. A v0.8.x restore with--cleanalso leaves tables that files after 180 created. Only a restore into an empty database is always right, and the state check refuses the rest. - G. Port
cmd/hardcount-migrate-safety. Rejected: it steps through the old files, which no longer ship. A replacement waits for its trigger (below).
Consequences
Section titled “Consequences”Positive
Section titled “Positive”- No dirty state and no
force. After a failure, fix the cause and run the upgrade again. - Several copies may migrate as they start.
- About 1,535 lines of runner code and 1,694 lines of tests give way to about 350 lines of code and new tests.
- A new install runs one file instead of 178.
- CI proves on every PR that a new install equals an upgraded one, access rights included, under three role setups.
- An index build can be rerun safely after any crash.
Risks / open items
Section titled “Risks / open items”- Every v0.8.x install changes its version table once. Adoption adds
goose_db_versionand a table comment, and changes no other table. Installs older than v0.8.0 upgrade to v0.8.1 first. - With
RUN_MIGRATIONS_ON_BOOT=false(the default outside Compose), runhardcount migrateonce before starting v0.9.0. Until then v0.9.0 refuses to start and says one migration is waiting. The release notes say so. - A v0.8.x backup restores only into an empty database. Restored over
an adopted database, the state check refuses and changes nothing.
(2026-10-19: from migration 185 on, a table created after 180 holds a
foreign key into the backup’s tables. So
scripts/restore.shstops first:pg_restore --cleancannot drop those tables, and the single-transaction restore is rolled back. Nothing changes either way; the CI restore drill checks both outcomes, accepts the rollback only for that cause, and also restores the backup into an empty database, which must succeed.) - v0.8.x binaries can still write
schema_migrationsafter adoption, throughmigrate rollbackormigrate force. The state check catches it on the next v0.9 start. To go back, restore the backup. When no file after 180 has run, the v0.8.1 image can also run as it is. - Adoption needs to own
schema_migrationsto add the comment. If v0.9 migrates as a different role than v0.8 did, adoption fails with “must be owner” and changes nothing. - The
hardcount migrate statusoutput changes. Scripts that read the oldversion: N, dirty: …line break. None in this repo does. hardcount migrate rollbackis local only, and the old down files no longer ship. The ADR-107 amendment covers migration 0009’s rollback.- The
migrationsjob pulls the v0.8.1 image from GHCR by digest. If GHCR is down, the job fails loudly. - A role-level
statement_timeouton the migrating user cuts off a long index build, as it does today. hardcount_appkeeps all rights ongoose_db_versionwhere default privileges grant them, as it has onschema_migrationstoday.- Old code on a new schema is still not covered. Deferred work, each
with its trigger:
- An expand/contract rule (old and new code share one schema during an upgrade). Trigger: the first partner who needs upgrades without downtime across several copies.
- A tool that times migrations on populated tables. Trigger: the first
file after 180 that touches
usage_events,invoices,subscriptions,customer_credit_ledgeroraudit_log. - A replacement for
hardcount-migrate-safety. Trigger: the first file after 180 that moves data. - At the next baseline: delete migration 180’s adopt branch, the table
comment check and the three refusals, and drop the frozen
schema_migrations. - Move to the current goose release with the next by-hand Go dependency pass.
References
Section titled “References”- ADR-124, superseded by this ADR; its experiments E1, E2 and E7 still stand.
- ADR-073: decision 3, the hybrid runner, is replaced (amendment 2026-10-18).
- ADR-107: migration 0009’s rollback (amendment 2026-10-18).
- #967 (dirty repair), #987 (lock timeout), #1170 (ADR-124), #1171 (the status output this design reuses)
- Migrations 0062, 0112 and 0130 (
CONCURRENTLY), 0150 (the onlyREVOKE), 0174 (theleader_leasesseed), 0180 (the last v0.8.1 file) - Experiments: CI run 39047170196
- golang-migrate #960, #962, #1451
- goose v3.27.0 source:
provider_run.go(the version row in the file’s transaction, or after a no-transaction file),lock/postgres.go(the polling lock, id 4097083626) feedback_prefer_battle_tested_libs,feedback_design_principles_simple_correct_standard,feedback_no_overengineering