Files
kandev/apps/backend/internal/persistence/snapshot.go
T
2026-09-12 16:29:38 +01:00

130 lines
3.7 KiB
Go

package persistence
import (
"context"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/jmoiron/sqlx"
)
// snapshotPath returns the absolute path for a new backup file.
// fromVersion defaults to "pre-meta" when empty.
func snapshotPath(backupDir, fromVersion string) string {
v := fromVersion
if v == "" {
v = "pre-meta"
}
ts := time.Now().UTC().Format("20060102T150405Z")
name := fmt.Sprintf("kandev-%s-%s.db", v, ts)
return filepath.Join(backupDir, name)
}
// SnapshotSQLite copies the live database to path using VACUUM INTO,
// which produces a clean, defragmented snapshot including all WAL frames.
// Returns the size of the created file in bytes. Exported for use by
// internal/system/backups; the lowercase alias preserves call sites in
// this package.
func SnapshotSQLite(writer *sqlx.DB, path string) (int64, error) {
return snapshotSQLite(writer, path)
}
// snapshotSQLite copies the live database to path using VACUUM INTO,
// which produces a clean, defragmented snapshot including all WAL frames.
// Returns the size of the created file in bytes.
func snapshotSQLite(writer *sqlx.DB, path string) (int64, error) {
return snapshotSQLiteContext(context.Background(), writer, path)
}
func snapshotSQLiteContext(ctx context.Context, writer *sqlx.DB, path string) (int64, error) {
_, statErr := os.Lstat(path)
if statErr != nil && !os.IsNotExist(statErr) {
return 0, fmt.Errorf("inspect snapshot destination %s: %w", path, statErr)
}
pathExisted := statErr == nil
installed := false
defer func() {
if !installed && !pathExisted {
// VACUUM INTO writes the destination incrementally. A canceled or
// failed statement can leave a partial SQLite file behind, which
// must not be mistaken for a usable backup on retry.
_ = os.Remove(path)
}
}()
if _, err := writer.ExecContext(ctx, `VACUUM INTO ?`, path); err != nil {
return 0, fmt.Errorf("vacuum into %s: %w", path, err)
}
if err := ctx.Err(); err != nil {
return 0, fmt.Errorf("snapshot canceled after vacuum into %s: %w", path, err)
}
info, err := os.Stat(path)
if err != nil {
return 0, fmt.Errorf("stat snapshot %s: %w", path, err)
}
if err := ctx.Err(); err != nil {
return 0, fmt.Errorf("snapshot canceled before installing %s: %w", path, err)
}
installed = true
return info.Size(), nil
}
// PruneBackups is the exported alias of pruneBackups, intended for callers
// outside this package (e.g. internal/system/backups retention tests) that
// need to verify the auto-snapshot retention policy without copy-pasting
// the implementation.
func PruneBackups(dir string, keep int) error {
return pruneBackups(dir, keep)
}
// pruneBackups retains only the keep newest backup files (sorted by mtime)
// inside dir, deleting everything older. Files are matched by the
// "kandev-*.db" glob pattern. Non-matching files in the directory are not
// touched. Errors on individual deletes are silently ignored so a stale
// permission issue on one file does not block the rest.
func pruneBackups(dir string, keep int) error {
entries, err := os.ReadDir(dir)
if err != nil {
return fmt.Errorf("read backup dir %s: %w", dir, err)
}
type fileInfo struct {
path string
mtime time.Time
}
var files []fileInfo
for _, e := range entries {
if e.IsDir() {
continue
}
if !strings.HasPrefix(e.Name(), "kandev-") || !strings.HasSuffix(e.Name(), ".db") {
continue
}
info, err := e.Info()
if err != nil {
continue
}
files = append(files, fileInfo{
path: filepath.Join(dir, e.Name()),
mtime: info.ModTime(),
})
}
if len(files) <= keep {
return nil
}
// Sort newest first.
sort.Slice(files, func(i, j int) bool {
return files[i].mtime.After(files[j].mtime)
})
for _, f := range files[keep:] {
_ = os.Remove(f.path)
}
return nil
}