metrics: drain freelist proportionally so metrics.db stops bloating

runRetention reclaimed only PRAGMA incremental_vacuum(5000) (~20MB) and
only when that hourly cycle deleted rows. On instances where an hourly
retention pass frees more than 5000 pages, the freelist grows net-positive
every cycle and the sqlite file bloats unboundedly (5GB+ of free pages over
~60MB of live data) even though row retention works. This is the documented
'50+ resources -> 5GB+' symptom the 5000-page batch was meant to fix.

Reclaim every cycle (so a pre-existing backlog drains even in a no-delete
hour) and size each reclaim to the current freelist, capped at 50000 pages
(~200MB) so a large backlog drains over several cycles without holding the
write lock for minutes. Skip the checkpoint when the freelist is empty so
steady-state WAL cadence is unchanged.

Surfaced by the demo server (pulse-relay): metrics.db had grown to 5.9GB
(5GB freelist), filling its 25GB disk and breaking the nightly backup.

Adds TestStoreRetentionReclaimsFreePages to the perf-and-scalability proof
set and documents the reclaim invariant in the subsystem contract.
This commit is contained in:
rcourtman 2026-06-05 13:29:48 +01:00
parent 2199423821
commit 2b8ce06c03
3 changed files with 122 additions and 8 deletions

View file

@ -1735,16 +1735,44 @@ func (s *Store) runRetention() {
Int64("deleted", totalDeleted).
Dur("duration", time.Since(start)).
Msg("Metrics retention cleanup completed")
}
// Reclaim disk space from deleted rows. Without this, the database
// file never shrinks — a setup with 50+ resources can bloat to 5GB+
// while only holding ~60MB of live data.
if _, err := s.db.Exec(`PRAGMA incremental_vacuum(5000)`); err != nil {
// Reclaim freed pages every cycle, even in an hour where nothing was
// deleted, so a pre-existing freelist backlog still drains over time.
s.reclaimFreePages()
}
// maxReclaimPages bounds how many freed SQLite pages reclaimFreePages returns
// to the OS per retention cycle (~50k pages * 4KiB ≈ 200MiB). A large backlog
// is drained over several hourly cycles instead of holding the write lock for
// minutes at once, while steady-state freelists (well under the cap) drain
// fully every cycle.
const maxReclaimPages = 50000
// reclaimFreePages returns freed pages to the OS. auto_vacuum is INCREMENTAL,
// so deleted-row pages sit on the freelist until reclaimed here. A previous
// fixed 5000-page batch could not keep up on busy instances: when an hourly
// retention pass frees more pages than the batch reclaims, the freelist grows
// net-positive every cycle and the database file bloats unboundedly (5GB+ of
// free pages over ~60MB of live data) even though row retention is working.
// Draining proportionally to the freelist, capped, fixes that.
func (s *Store) reclaimFreePages() {
var freelist int64
if err := s.db.QueryRow(`PRAGMA freelist_count`).Scan(&freelist); err != nil {
log.Debug().Err(err).Msg("Failed to read freelist_count")
freelist = maxReclaimPages // fall back to a bounded reclaim
}
if freelist > 0 {
pages := freelist
if pages > maxReclaimPages {
pages = maxReclaimPages
}
if _, err := s.db.Exec(fmt.Sprintf(`PRAGMA incremental_vacuum(%d)`, pages)); err != nil {
log.Debug().Err(err).Msg("Incremental vacuum failed")
}
if _, err := s.db.Exec(`PRAGMA wal_checkpoint(TRUNCATE)`); err != nil {
log.Debug().Err(err).Msg("WAL checkpoint failed")
}
}
if _, err := s.db.Exec(`PRAGMA wal_checkpoint(TRUNCATE)`); err != nil {
log.Debug().Err(err).Msg("WAL checkpoint failed")
}
}

View file

@ -828,3 +828,81 @@ func TestStoreQueryMetricTypesBatchFiltersMetricTypes(t *testing.T) {
t.Fatalf("expected metric filter to exclude memory for agent-2, got %+v", result["agent-2"])
}
}
// TestStoreRetentionReclaimsFreePages guards the fix for unbounded metrics.db
// growth. A pre-existing freelist backlog must drain on a normal retention pass
// even in an hour where nothing new was deleted. The previous code reclaimed
// only when that cycle deleted rows, and only 5000 pages at a time, so on busy
// instances the freelist outpaced reclaim and the file bloated to GBs over
// ~60MB of live data. Reclaiming every cycle, proportional to the freelist,
// returns the freed pages to the OS so the file shrinks.
func TestStoreRetentionReclaimsFreePages(t *testing.T) {
dir := t.TempDir()
cfg := DefaultConfig(dir)
cfg.DBPath = filepath.Join(dir, "metrics-reclaim.db")
cfg.RetentionRaw = time.Minute
cfg.FlushInterval = time.Hour
store, err := NewStore(cfg)
if err != nil {
t.Fatalf("NewStore returned error: %v", err)
}
defer store.Close()
// Build a freelist backlog: insert many rows (kept unique by timestamp) then
// delete them directly, so the rows are already gone before runRetention.
tx, err := store.db.Begin()
if err != nil {
t.Fatalf("begin tx: %v", err)
}
stmt, err := tx.Prepare(`INSERT INTO metrics (resource_type, resource_id, metric_type, value, timestamp, tier) VALUES ('vm', 'vm-101', 'cpu', 1.0, ?, 'raw')`)
if err != nil {
t.Fatalf("prepare: %v", err)
}
base := time.Now().Add(-72 * time.Hour).Unix()
for i := 0; i < 20000; i++ {
if _, err := stmt.Exec(base + int64(i)); err != nil {
t.Fatalf("insert: %v", err)
}
}
stmt.Close()
if err := tx.Commit(); err != nil {
t.Fatalf("commit: %v", err)
}
if _, err := store.db.Exec(`DELETE FROM metrics`); err != nil {
t.Fatalf("delete: %v", err)
}
if _, err := store.db.Exec(`PRAGMA wal_checkpoint(TRUNCATE)`); err != nil {
t.Fatalf("checkpoint: %v", err)
}
var freelistBefore, pagesBefore int64
if err := store.db.QueryRow(`PRAGMA freelist_count`).Scan(&freelistBefore); err != nil {
t.Fatalf("freelist before: %v", err)
}
if err := store.db.QueryRow(`PRAGMA page_count`).Scan(&pagesBefore); err != nil {
t.Fatalf("page_count before: %v", err)
}
if freelistBefore == 0 {
t.Fatalf("test precondition failed: expected a freelist backlog, got 0 free pages")
}
// Nothing to prune this cycle (table is already empty), but the backlog must
// still be reclaimed. The old code skipped reclaim entirely when nothing was
// deleted, so the file would not shrink and this would fail.
store.runRetention()
var freelistAfter, pagesAfter int64
if err := store.db.QueryRow(`PRAGMA freelist_count`).Scan(&freelistAfter); err != nil {
t.Fatalf("freelist after: %v", err)
}
if err := store.db.QueryRow(`PRAGMA page_count`).Scan(&pagesAfter); err != nil {
t.Fatalf("page_count after: %v", err)
}
if pagesAfter >= pagesBefore {
t.Fatalf("expected database file to shrink after reclaim: pages before=%d after=%d (freelist %d -> %d)", pagesBefore, pagesAfter, freelistBefore, freelistAfter)
}
if freelistAfter >= freelistBefore {
t.Fatalf("expected freelist to shrink after reclaim: %d -> %d", freelistBefore, freelistAfter)
}
}