hsa-app/internal/backup/backup.go

125 lines
3.4 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-backup-"
suffix = ".db"
stamp = "2006-01-02-150405"
)
// Schedule runs metadata-only backups into dir every `every`, keeping the most
// recent `keep`. It blocks until ctx is cancelled, so run it in a goroutine.
//
// On start it backs up immediately unless a backup younger than `every` already
// exists — so frequent restarts don't spam the directory, and a gap longer than
// the interval is covered right away. `now` is injected for testability (pass
// time.Now).
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 {
timer := time.NewTimer(untilNext(dir, every, now()))
select {
case <-ctx.Done():
timer.Stop()
return
case <-timer.C:
}
if err := writeBackup(s, dir, now()); err != nil {
log.Printf("backup: %v", err)
continue
}
if err := prune(dir, keep); err != nil {
log.Printf("backup prune: %v", err)
}
}
}
// untilNext is how long to wait before the next backup: zero if none exists or the
// newest is already older than `every`, otherwise the remaining time.
func untilNext(dir string, every time.Duration, now time.Time) time.Duration {
newest := newestBackup(dir)
if newest.IsZero() {
return 0
}
if elapsed := now.Sub(newest); elapsed < every {
return every - elapsed
}
return 0
}
func writeBackup(s Snapshotter, dir string, now time.Time) error {
name := prefix + now.UTC().Format(stamp) + suffix
if err := s.SnapshotTo(filepath.Join(dir, name), 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
}
func newestBackup(dir string) time.Time {
names := backupFiles(dir)
if len(names) == 0 {
return time.Time{}
}
last := strings.TrimSuffix(strings.TrimPrefix(names[len(names)-1], prefix), suffix)
t, err := time.Parse(stamp, last) // layout has no zone → parsed as UTC
if err != nil {
return time.Time{}
}
return t
}
// 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
}