mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
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:
parent
2199423821
commit
2b8ce06c03
3 changed files with 122 additions and 8 deletions
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue