2026-06-19 11:12:50 +00:00
|
|
|
// 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 (
|
2026-06-19 11:34:31 +00:00
|
|
|
prefix = "hsa_sqlite_backup_"
|
2026-06-19 11:12:50 +00:00
|
|
|
suffix = ".db"
|
2026-06-19 11:34:31 +00:00
|
|
|
stamp = "2006_01_02" // yyyy_mm_dd — one backup per day
|
2026-06-19 11:12:50 +00:00
|
|
|
)
|
|
|
|
|
|
2026-06-19 11:34:31 +00:00
|
|
|
// 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).
|
2026-06-19 11:12:50 +00:00
|
|
|
//
|
2026-06-19 11:34:31 +00:00
|
|
|
// 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.
|
2026-06-19 11:12:50 +00:00
|
|
|
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 {
|
2026-06-19 11:34:31 +00:00
|
|
|
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)
|
2026-06-19 11:12:50 +00:00
|
|
|
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
|
2026-06-19 11:34:31 +00:00
|
|
|
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 {
|
2026-06-19 11:12:50 +00:00
|
|
|
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
|
|
|
|
|
}
|