hsa-app/internal/backup/backup.go

101 lines
3 KiB
Go
Raw Normal View History

// Package backup writes periodic metadata-only snapshots of the database to a
// local directory, on a schedule, so a fresh copy of the receipt metadata always
// survives even if the live DB is lost. Blobs (receipt + attachment images) are
// excluded to keep the backups small — the on-disk files and the full-blob export
// remain the source for the images themselves.
package backup
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// Snapshotter writes a point-in-time DB copy. *storage.Store satisfies this.
type Snapshotter interface {
SnapshotTo(destPath string, includeBlobs bool) error
}
const (
prefix = "hsa_sqlite_backup_"
suffix = ".db"
stamp = "2006_01_02" // yyyy_mm_dd — one backup per day
)
// Schedule runs metadata-only backups into dir, attempting one on start and then
// every `every`, keeping the most recent `keep`. It blocks until ctx is cancelled,
// so run it in a goroutine. `now` is injected for testability (pass time.Now).
//
// Backups are idempotent per day (date-only filenames): a write whose dated file
// already exists is a no-op, so frequent restarts — or intervals shorter than a
// day — never duplicate or spam, they just settle at one backup per day.
func Schedule(ctx context.Context, s Snapshotter, dir string, every time.Duration, keep int, now func() time.Time) {
if err := os.MkdirAll(dir, 0o700); err != nil {
log.Printf("backup: cannot create %s: %v (backups disabled)", dir, err)
return
}
for {
if err := writeBackup(s, dir, now()); err != nil {
log.Printf("backup: %v", err)
} else if err := prune(dir, keep); err != nil {
log.Printf("backup prune: %v", err)
}
timer := time.NewTimer(every)
select {
case <-ctx.Done():
timer.Stop()
return
case <-timer.C:
}
}
}
func writeBackup(s Snapshotter, dir string, now time.Time) error {
name := prefix + now.UTC().Format(stamp) + suffix
dest := filepath.Join(dir, name)
if _, err := os.Stat(dest); err == nil {
return nil // today's backup already exists (date-only name) — nothing to do
}
if err := s.SnapshotTo(dest, false); err != nil {
return fmt.Errorf("write %s: %w", name, err)
}
log.Printf("backup: wrote %s", name)
return nil
}
// backupFiles returns the backup filenames in chronological order (the timestamped
// names sort lexically the same as by time).
func backupFiles(dir string) []string {
ents, err := os.ReadDir(dir)
if err != nil {
return nil
}
var names []string
for _, e := range ents {
if !e.IsDir() && strings.HasPrefix(e.Name(), prefix) && strings.HasSuffix(e.Name(), suffix) {
names = append(names, e.Name())
}
}
sort.Strings(names)
return names
}
// prune deletes the oldest backups beyond the most recent `keep`.
func prune(dir string, keep int) error {
names := backupFiles(dir)
if keep <= 0 || len(names) <= keep {
return nil
}
for _, name := range names[:len(names)-keep] {
if err := os.Remove(filepath.Join(dir, name)); err != nil {
return err
}
}
return nil
}