a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
64 lines
2.9 KiB
Go
64 lines
2.9 KiB
Go
package store_sqlite
|
||
|
||
// One-time boot compaction support.
|
||
//
|
||
// The graph store only ever grows on disk: deleted rows (a purged repo, the
|
||
// duplicate-collapse migration, resolver cleanups) return their pages to
|
||
// SQLite's freelist, where future writes reuse them — but nothing short of
|
||
// VACUUM returns them to the filesystem. A long-lived store that shed a large
|
||
// fraction of its rows can therefore pin gigabytes of dead file forever (a
|
||
// live store sat at 64% freelist — 4.4 GB reclaimable in a 6.8 GB file).
|
||
// These methods give the daemon the numbers to decide whether that one-time
|
||
// rewrite is worth it, and the lever to run it. The policy (thresholds, disk
|
||
// headroom, kill-switch) deliberately lives with the caller: the store cannot
|
||
// know whether minutes of exclusive I/O are acceptable right now.
|
||
|
||
// Path returns the on-disk database file path. Empty when the store was not
|
||
// opened from a file — callers using it to reason about the underlying
|
||
// filesystem (disk-headroom checks) must treat "" as "unknown, don't".
|
||
func (s *Store) Path() string {
|
||
return s.dbPath
|
||
}
|
||
|
||
// CompactStats reports how much of the database file is reclaimable dead
|
||
// space: freeBytes is the freelist (freelist_count × page_size), totalBytes
|
||
// the whole main file (page_count × page_size). Zeros on any pragma error —
|
||
// a read failing here is the same teardown race panicOnFatal swallows, and
|
||
// "nothing reclaimable" is the answer that makes every caller do nothing.
|
||
// The -wal file is excluded on purpose: the checkpoint loop already bounds
|
||
// it, and VACUUM only rewrites the main file.
|
||
func (s *Store) CompactStats() (freeBytes, totalBytes int64) {
|
||
var pageSize, pageCount, freePages int64
|
||
if err := s.db.QueryRow(`PRAGMA page_size`).Scan(&pageSize); err != nil {
|
||
return 0, 0
|
||
}
|
||
if err := s.db.QueryRow(`PRAGMA page_count`).Scan(&pageCount); err != nil {
|
||
return 0, 0
|
||
}
|
||
if err := s.db.QueryRow(`PRAGMA freelist_count`).Scan(&freePages); err != nil {
|
||
return 0, 0
|
||
}
|
||
return freePages * pageSize, pageCount * pageSize
|
||
}
|
||
|
||
// Compact rewrites the database file (VACUUM), returning freelist pages to
|
||
// the filesystem, then drains the write-ahead log with a TRUNCATE checkpoint
|
||
// so the rewrite's WAL traffic doesn't linger as a second oversized file.
|
||
//
|
||
// Cost model callers must respect: VACUUM copies the live content into a
|
||
// temporary database (up to a full extra copy on the same filesystem) and
|
||
// needs exclusive access — it blocks Go-side writers via writeMu here, and a
|
||
// concurrent reader on another pooled connection makes SQLite wait out
|
||
// busy_timeout and then fail. That failure is clean (the store is untouched,
|
||
// freelist pages remain reusable), which is why the daemon treats a Compact
|
||
// error as skip-and-continue rather than fatal.
|
||
func (s *Store) Compact() error {
|
||
s.writeMu.Lock()
|
||
_, err := s.db.Exec(`VACUUM`)
|
||
s.writeMu.Unlock()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return s.CheckpointWAL()
|
||
}
|