package main // Compacting a database. // // SQLite never shrinks a file on its own: deleting rows — or dropping a table, // which is what the schema-role cleanup does — frees pages INSIDE the file and // leaves the file the size it always was. A logbook that held 200 000 imported // QSOs for a day is still a 200 MB file the day after they are gone, and the // only thing that reclaims the space is a VACUUM, which rewrites the database // from scratch. // // Offered as a button rather than done automatically: a VACUUM rewrites the // whole file, needs room for a second copy of it while it runs, and takes real // time on a large logbook. That is a decision for the operator, at a moment they // choose — not something to spring on them during startup. import ( "database/sql" "fmt" "os" "strings" "hamlog/internal/applog" ) // CompactResult is what one compaction did. type CompactResult struct { // Path of the file compacted, empty for MySQL. Path string `json:"path"` // Backend is "sqlite" or "mysql" — a shared server is optimised, not vacuumed. Backend string `json:"backend"` // Before and After are file sizes in bytes; both 0 when there is no file. Before int64 `json:"before"` After int64 `json:"after"` } // CompactDatabase reclaims the unused space in one of the two databases. // // target is "settings" or "logbook". They are separate on purpose: they are // different files, of very different sizes, and an operator compacting a 400 MB // logbook has no reason to wait on a 2 MB settings file as well. func (a *App) CompactDatabase(target string) (CompactResult, error) { switch strings.ToLower(strings.TrimSpace(target)) { case "settings": if a.db == nil { return CompactResult{}, fmt.Errorf("the settings database is not open") } return a.vacuumSQLite(a.db, a.dbPath) case "logbook": if a.logDb == nil { return CompactResult{}, fmt.Errorf("the logbook is not open") } if a.dbBackend == "mysql" { // A shared server's storage is the admin's business, and OPTIMIZE TABLE // locks the table for the length of a rebuild — every other operator // waits. Still offered, because a logbook that has had a large import // deleted benefits from it just as much; it simply reports no sizes, // which the server alone knows. if _, err := a.logDb.ExecContext(a.ctx, "OPTIMIZE TABLE qso"); err != nil { return CompactResult{}, fmt.Errorf("optimize qso: %w", err) } applog.Printf("compact: OPTIMIZE TABLE qso on the shared MySQL logbook") return CompactResult{Backend: "mysql"}, nil } // No separate file: the settings database is serving as the logbook, and // compacting it is the same operation. if a.logDbPath == "" { return a.vacuumSQLite(a.db, a.dbPath) } return a.vacuumSQLite(a.logDb, a.logDbPath) } return CompactResult{}, fmt.Errorf("unknown database %q", target) } // vacuumSQLite checkpoints the write-ahead log, then rewrites the file. func (a *App) vacuumSQLite(conn *sql.DB, path string) (CompactResult, error) { res := CompactResult{Path: path, Backend: "sqlite", Before: fileSizeOf(path)} // Fold the WAL back into the main file first. Without it the pages freed by a // recent DELETE can still be sitting in the -wal, and the vacuum reports a // saving the file on disk does not show. if _, err := conn.ExecContext(a.ctx, "PRAGMA wal_checkpoint(TRUNCATE)"); err != nil { applog.Printf("compact: wal checkpoint on %s: %v", path, err) } if _, err := conn.ExecContext(a.ctx, "VACUUM"); err != nil { return res, fmt.Errorf("vacuum: %w", err) } res.After = fileSizeOf(path) applog.Printf("compact: %s %d → %d bytes", path, res.Before, res.After) return res, nil } // fileSizeOf returns a file's size, or 0 if it cannot be read. func fileSizeOf(path string) int64 { if strings.TrimSpace(path) == "" { return 0 } fi, err := os.Stat(path) if err != nil { return 0 } return fi.Size() }