This commit is contained in:
2026-06-10 20:27:44 +02:00
parent 42b5c6247d
commit 6150498a9e
9 changed files with 223 additions and 120 deletions
+33 -18
View File
@@ -36,13 +36,14 @@ func DefaultFolder(dataDir string) string {
return filepath.Join(dataDir, "backups")
}
// Run executes one backup pass: checkpoint WALcopy the database file
// → optionally zip → rotate. Returns the path of the file that was
// written so the caller can surface it to the UI.
// Run executes one backup pass: VACUUM INTO a fresh snapshot → optionally
// zip → rotate. Returns the path of the file that was written so the caller
// can surface it to the UI.
//
// dbConn is used to issue the WAL checkpoint so the on-disk file is
// self-consistent before we copy it. It's the same *sql.DB the app uses;
// SQLite tolerates concurrent reads during the copy.
// VACUUM INTO produces a transactionally-consistent copy in a single SQL
// statement (no torn-copy window while the app keeps writing), and compacts
// the destination as a bonus. It replaces the old "checkpoint + raw io.Copy",
// which could capture a half-written page during a concurrent write.
func Run(ctx context.Context, dbConn *sql.DB, dbPath, folder string, rotation int, doZip bool) (string, error) {
if dbConn == nil {
return "", fmt.Errorf("nil db connection")
@@ -56,24 +57,38 @@ func Run(ctx context.Context, dbConn *sql.DB, dbPath, folder string, rotation in
if err := os.MkdirAll(folder, 0o755); err != nil {
return "", fmt.Errorf("create backup folder: %w", err)
}
// Flush WAL into the main file so a raw copy is a complete database.
// TRUNCATE removes the -wal file's contents after checkpointing.
if _, err := dbConn.ExecContext(ctx, `PRAGMA wal_checkpoint(TRUNCATE);`); err != nil {
return "", fmt.Errorf("wal_checkpoint: %w", err)
}
stamp := time.Now().Format("2006-01-02")
base := fmt.Sprintf("opslog-%s", stamp)
// VACUUM INTO requires a non-existent target → use a temp file, then
// move/zip it into place.
tmp := filepath.Join(folder, base+".vacuum.tmp")
_ = os.Remove(tmp)
if _, err := dbConn.ExecContext(ctx, `VACUUM INTO ?;`, tmp); err != nil {
_ = os.Remove(tmp)
return "", fmt.Errorf("vacuum into: %w", err)
}
var dstPath string
if doZip {
dstPath = filepath.Join(folder, base+".db.zip")
if err := copyZipped(dbPath, dstPath); err != nil {
// Inner name = the live DB's base so unzip restores e.g. "opslog.db".
if err := copyZipped(tmp, dstPath, filepath.Base(dbPath)); err != nil {
_ = os.Remove(tmp)
return "", err
}
_ = os.Remove(tmp)
} else {
dstPath = filepath.Join(folder, base+".db")
if err := copyFile(dbPath, dstPath); err != nil {
return "", err
_ = os.Remove(dstPath)
if err := os.Rename(tmp, dstPath); err != nil {
// Rename can fail across filesystems — fall back to a copy.
if cerr := copyFile(tmp, dstPath); cerr != nil {
_ = os.Remove(tmp)
return "", cerr
}
_ = os.Remove(tmp)
}
}
@@ -105,9 +120,9 @@ func copyFile(src, dst string) error {
}
// copyZipped writes a single-entry deflate zip containing the database.
// The inner filename is just the base of the source so unzip restores
// "hamlog.db" wherever the user extracts.
func copyZipped(src, dst string) error {
// innerName is the entry name inside the zip (the live DB's base name) so
// unzip restores e.g. "opslog.db" wherever the user extracts.
func copyZipped(src, dst, innerName string) error {
in, err := os.Open(src)
if err != nil {
return fmt.Errorf("open source: %w", err)
@@ -119,7 +134,7 @@ func copyZipped(src, dst string) error {
return fmt.Errorf("create dest: %w", err)
}
zw := zip.NewWriter(out)
w, err := zw.Create(filepath.Base(src))
w, err := zw.Create(innerName)
if err != nil {
zw.Close()
out.Close()