125 lines
3.4 KiB
Go
125 lines
3.4 KiB
Go
|
|
// 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
|
||
|
|
}
|