103 lines
3.8 KiB
Go
103 lines
3.8 KiB
Go
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()
|
|
}
|