Skip to content

fix(db): prune pre-migration backups so db_backups stops growing unbounded - #10423

Merged
diegosouzapw merged 3 commits into
release/v3.8.50from
fix/db-backup-retention-10421
Aug 15, 2026
Merged

diegosouzapw merged 3 commits into
release/v3.8.50from
fix/db-backup-retention-10421

Conversation

@diegosouzapw

Copy link
Copy Markdown
Owner

⚠️ base-red inherited: #9985

Fixes #10421.

Problem

createPreMigrationBackup() writes a VACUUM INTO snapshot on every migration run and never prunes. Two machines hit the same wall:

host files in db_backups/ size live DB
devbox (this session) 48.999 204 GB 5,3 MB
devbox-vm-06-dev002 5.711 24 GB 5,3 MB

Every process start that opens the DB with a pending migration adds ~4,7 MB, forever. On the first host this took the disk to 97% full.

Root cause

The retention policy already existed and was complete — cleanupDbBackups() in src/lib/db/backup.ts, honoring the operator's maxFiles / retentionDays. Nothing on the migration path ever reached it: its only callers are backup.ts itself and the /api/db-backups route, neither of which runs during a migration. The pre-migration snapshot was the single backup producer with no retention at all — and by far the most frequent one.

Fix

migrationRunner.ts cannot simply import backup.ts: core.ts imports migrationRunner.ts and backup.ts imports core.ts, so that edge would close a dependency cycle (npm run check:cycles would fail).

So the policy moves into a new core-free module, src/lib/db/backupRetention.ts, shared by both call sites:

  • cleanupDbBackups() now delegates to it instead of carrying its own copy — same behavior, no duplicated policy. It also accepts an optional backupDir.
  • migrationRunner.ts prunes right after writing the snapshot.

Two details at the migration call site:

  • Settings are read through the adapter already open for the run. backup.ts resolves them via getDbInstance(); doing that here would re-enter database initialization, since this code runs from inside it. Precedence is unchanged (env override → persisted operator setting → default), and a DB too old to have key_value falls back to the default.
  • Pruning never throws. Housekeeping must not fail a migration.

Validation (Hard Rule #18 — TDD)

tests/unit/db-pre-migration-backup-retention-10421.test.ts, written before the fix:

# before the fix
✖ #10421 runMigrations prunes pre-migration backups to the configured maxFiles
  AssertionError: expected retention to cap db_backups at 5 files, found 31
✖ #10421 the newest pre-migration backup survives pruning
  AssertionError: expected <=3 backups, found 11

# after
✔ #10421 runMigrations prunes pre-migration backups to the configured maxFiles
✔ #10421 the newest pre-migration backup survives pruning

The second test pins that the snapshot taken by the current run survives — pruning must never discard the backup protecting the migration it was taken for.

Also green locally:

  • npm run check:cycles — no cycles across 413 files (the constraint that shaped this fix)
  • npm run typecheck:core — clean
  • 60 pre-existing tests across db-backup-extended, db-backup-autobackup-setting-5871, db-backups-skills-3500, db-migration-runner, db-core-migration

Docs

docs/reference/ENVIRONMENT.md: DB_BACKUP_MAX_FILES / DB_BACKUP_RETENTION_DAYS now also list migrationRunner.ts as a read site and state that they apply to pre-migration snapshots.

…unded

createPreMigrationBackup() wrote a VACUUM INTO snapshot on every migration run
and never pruned. On a long-lived instance db_backups/ reached 48.999 files /
204 GB against a 5,3 MB live database; a second devbox showed the same shape
(5.711 files / 24 GB).

The retention policy already existed in cleanupDbBackups() but nothing on the
migration path reached it — its only callers are backup.ts and the
/api/db-backups route, neither of which runs during a migration.

migrationRunner.ts cannot import backup.ts: core.ts imports migrationRunner.ts
and backup.ts imports core.ts, so that edge would close a cycle. The policy
therefore moves to a new core-free module, backupRetention.ts, which both call
sites share — cleanupDbBackups() now delegates to it rather than duplicating it.

At the migration call site the operator's maxFiles/retentionDays are read
through the adapter already open for the run; going through getDbInstance()
would re-enter database initialization. Pruning never throws, so housekeeping
cannot fail a migration.

Closes #10421
…dule

check:db-rules requires every src/lib/db/ module to be either re-exported by
localDb.ts or listed in INTENTIONALLY_INTERNAL. backupRetention.ts is a shared
primitive consumed only by db/backup.ts and db/migrationRunner.ts — the same
category as the migrationRunner entry — so it belongs in the allowlist rather
than in the public re-export surface.
…AL list

check-db-rules-classification.test.ts freezes the exact membership of
INTENTIONALLY_INTERNAL, so adding the 40th entry has to be reflected there too
— the gate script and this test pin the same contract from opposite sides.
@diegosouzapw
diegosouzapw merged commit 4a53a53 into release/v3.8.50 Aug 15, 2026
22 checks passed
@diegosouzapw
diegosouzapw deleted the fix/db-backup-retention-10421 branch August 15, 2026 00:27
tkgo11 pushed a commit to tkgo11/OmniRoute that referenced this pull request Sep 23, 2026
…unded (diegosouzapw#10423)

* fix(db): prune pre-migration backups so db_backups stops growing unbounded

createPreMigrationBackup() wrote a VACUUM INTO snapshot on every migration run
and never pruned. On a long-lived instance db_backups/ reached 48.999 files /
204 GB against a 5,3 MB live database; a second devbox showed the same shape
(5.711 files / 24 GB).

The retention policy already existed in cleanupDbBackups() but nothing on the
migration path reached it — its only callers are backup.ts and the
/api/db-backups route, neither of which runs during a migration.

migrationRunner.ts cannot import backup.ts: core.ts imports migrationRunner.ts
and backup.ts imports core.ts, so that edge would close a cycle. The policy
therefore moves to a new core-free module, backupRetention.ts, which both call
sites share — cleanupDbBackups() now delegates to it rather than duplicating it.

At the migration call site the operator's maxFiles/retentionDays are read
through the adapter already open for the run; going through getDbInstance()
would re-enter database initialization. Pruning never throws, so housekeeping
cannot fail a migration.

Closes diegosouzapw#10421

* chore(db): declare backupRetention as an intentionally-internal db module

check:db-rules requires every src/lib/db/ module to be either re-exported by
localDb.ts or listed in INTENTIONALLY_INTERNAL. backupRetention.ts is a shared
primitive consumed only by db/backup.ts and db/migrationRunner.ts — the same
category as the migrationRunner entry — so it belongs in the allowlist rather
than in the public re-export surface.

* test(db): include backupRetention in the audited INTENTIONALLY_INTERNAL list

check-db-rules-classification.test.ts freezes the exact membership of
INTENTIONALLY_INTERNAL, so adding the 40th entry has to be reflected there too
— the gate script and this test pin the same contract from opposite sides.

---------

Co-authored-by: Xiangzhe <bakryun0718@proton.me>
muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
…unded (diegosouzapw#10423)

* fix(db): prune pre-migration backups so db_backups stops growing unbounded

createPreMigrationBackup() wrote a VACUUM INTO snapshot on every migration run
and never pruned. On a long-lived instance db_backups/ reached 48.999 files /
204 GB against a 5,3 MB live database; a second devbox showed the same shape
(5.711 files / 24 GB).

The retention policy already existed in cleanupDbBackups() but nothing on the
migration path reached it — its only callers are backup.ts and the
/api/db-backups route, neither of which runs during a migration.

migrationRunner.ts cannot import backup.ts: core.ts imports migrationRunner.ts
and backup.ts imports core.ts, so that edge would close a cycle. The policy
therefore moves to a new core-free module, backupRetention.ts, which both call
sites share — cleanupDbBackups() now delegates to it rather than duplicating it.

At the migration call site the operator's maxFiles/retentionDays are read
through the adapter already open for the run; going through getDbInstance()
would re-enter database initialization. Pruning never throws, so housekeeping
cannot fail a migration.

Closes diegosouzapw#10421

* chore(db): declare backupRetention as an intentionally-internal db module

check:db-rules requires every src/lib/db/ module to be either re-exported by
localDb.ts or listed in INTENTIONALLY_INTERNAL. backupRetention.ts is a shared
primitive consumed only by db/backup.ts and db/migrationRunner.ts — the same
category as the migrationRunner entry — so it belongs in the allowlist rather
than in the public re-export surface.

* test(db): include backupRetention in the audited INTENTIONALLY_INTERNAL list

check-db-rules-classification.test.ts freezes the exact membership of
INTENTIONALLY_INTERNAL, so adding the 40th entry has to be reflected there too
— the gate script and this test pin the same contract from opposite sides.

---------

Co-authored-by: Xiangzhe <bakryun0718@proton.me>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(db): pre-migration backups are never pruned — db_backups grew to 204 GB / 49k files

2 participants