diff --git a/.env.example b/.env.example index 4f73c18..376fcb6 100644 --- a/.env.example +++ b/.env.example @@ -34,8 +34,10 @@ STORAGE_DIR=./data MAX_UPLOAD_MB=32 # Scheduled metadata-only DB backups (no image blobs). On by default, weekly. -# BACKUP_INTERVAL accepts a Go duration (e.g. 168h, 24h); set 0 to disable. -BACKUP_DIR=./data/backups +# Files are named hsa_sqlite_backup_YYYY_MM_DD.db (one per day). +# BACKUP_INTERVAL sets the frequency: a Go duration (e.g. 168h weekly, 24h daily, +# 72h every 3 days); set 0 to disable. +BACKUP_DIR=./data/dbbackup BACKUP_INTERVAL=168h BACKUP_KEEP=8 diff --git a/internal/backup/backup.go b/internal/backup/backup.go index 129fe02..f0ebf3f 100644 --- a/internal/backup/backup.go +++ b/internal/backup/backup.go @@ -22,57 +22,46 @@ type Snapshotter interface { } const ( - prefix = "hsa-backup-" + prefix = "hsa_sqlite_backup_" suffix = ".db" - stamp = "2006-01-02-150405" + stamp = "2006_01_02" // yyyy_mm_dd — one backup per day ) -// 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. +// 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). // -// 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). +// 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 { - timer := time.NewTimer(untilNext(dir, every, now())) + 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: } - 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 { + 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) @@ -96,19 +85,6 @@ func backupFiles(dir string) []string { 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) diff --git a/internal/backup/backup_test.go b/internal/backup/backup_test.go index 65ab711..ac80a8d 100644 --- a/internal/backup/backup_test.go +++ b/internal/backup/backup_test.go @@ -18,7 +18,7 @@ func (f *fakeSnap) SnapshotTo(dest string, includeBlobs bool) error { return os.WriteFile(dest, []byte("snap"), 0o600) } -func TestWriteBackupAndNewest(t *testing.T) { +func TestWriteBackup_NamesAndSameDayNoOp(t *testing.T) { dir := t.TempDir() s := &fakeSnap{} now := time.Date(2026, 6, 10, 9, 0, 0, 0, time.UTC) @@ -27,35 +27,19 @@ func TestWriteBackupAndNewest(t *testing.T) { t.Fatal(err) } files := backupFiles(dir) - if len(files) != 1 || files[0] != "hsa-backup-2026-06-10-090000.db" { + if len(files) != 1 || files[0] != "hsa_sqlite_backup_2026_06_10.db" { t.Fatalf("files = %v", files) } - if got := newestBackup(dir); !got.Equal(now) { - t.Errorf("newestBackup = %v, want %v", got, now) + + // A same-day second call is a no-op: no error, still one file, no re-snapshot. + if err := writeBackup(s, dir, now.Add(time.Hour)); err != nil { + t.Fatal(err) } -} - -func TestUntilNext(t *testing.T) { - dir := t.TempDir() - every := 168 * time.Hour - now := time.Date(2026, 6, 10, 9, 0, 0, 0, time.UTC) - - // No backups yet → back up immediately. - if d := untilNext(dir, every, now); d != 0 { - t.Errorf("empty dir: untilNext = %v, want 0", d) + if files := backupFiles(dir); len(files) != 1 { + t.Errorf("same-day re-run produced %v, want 1 file", files) } - - // A fresh backup → wait nearly the full interval. - _ = writeBackup(&fakeSnap{}, dir, now.Add(-1*time.Hour)) - if d := untilNext(dir, every, now); d <= 0 || d > every { - t.Errorf("fresh backup: untilNext = %v, want ~167h", d) - } - - // An old backup → back up now. - dir2 := t.TempDir() - _ = writeBackup(&fakeSnap{}, dir2, now.Add(-200*time.Hour)) - if d := untilNext(dir2, every, now); d != 0 { - t.Errorf("stale backup: untilNext = %v, want 0", d) + if s.calls != 1 { + t.Errorf("SnapshotTo called %d times, want 1 (second was a no-op)", s.calls) } } @@ -74,7 +58,7 @@ func TestPruneKeepsMostRecent(t *testing.T) { t.Fatalf("after prune: %v", files) } // The two newest (Jan 04, Jan 05) survive. - if !strings.Contains(files[0], "2026-01-04") || !strings.Contains(files[1], "2026-01-05") { + if !strings.Contains(files[0], "2026_01_04") || !strings.Contains(files[1], "2026_01_05") { t.Errorf("wrong files kept: %v", files) } } @@ -91,7 +75,7 @@ func TestScheduleRunsOnceThenStops(t *testing.T) { // Wait for the immediate first backup to land. deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { - if _, err := os.Stat(filepath.Join(dir, "hsa-backup-2026-06-10-090000.db")); err == nil { + if _, err := os.Stat(filepath.Join(dir, "hsa_sqlite_backup_2026_06_10.db")); err == nil { return } time.Sleep(10 * time.Millisecond) diff --git a/internal/config/config.go b/internal/config/config.go index f68862f..e8ce6fd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -93,7 +93,7 @@ func Load() (Config, error) { // Scheduled metadata-only DB backups (on by default, weekly). Set // BACKUP_INTERVAL=0 to disable. - c.BackupDir = envOr("BACKUP_DIR", "./data/backups") + c.BackupDir = envOr("BACKUP_DIR", "./data/dbbackup") c.BackupEvery = 7 * 24 * time.Hour if v := strings.TrimSpace(os.Getenv("BACKUP_INTERVAL")); v != "" { if d, err := time.ParseDuration(v); err == nil { diff --git a/spec.md b/spec.md index 0324c9c..7a58765 100644 --- a/spec.md +++ b/spec.md @@ -317,11 +317,12 @@ external cron job. backup excludes image blobs from BOTH receipts and attachments, so it is tiny. The on-disk files and the full-blob export (/export/db) remain the source for the images themselves. - - On by default, weekly. Config: BACKUP_DIR (default ./data/backups), - BACKUP_INTERVAL (Go duration, default 168h; set 0 to disable), BACKUP_KEEP - (most-recent copies to retain, default 8). - - Files are named hsa-backup-.db so they sort chronologically; - older copies beyond BACKUP_KEEP are pruned. + - On by default, weekly. Config: BACKUP_DIR (default ./data/dbbackup), + BACKUP_INTERVAL (Go duration sets the frequency, default 168h; set 0 to + disable), BACKUP_KEEP (most-recent copies to retain, default 8). + - Files are named hsa_sqlite_backup_.db — one per day, sorting + chronologically; older copies beyond BACKUP_KEEP are pruned. A same-day re-run + is a no-op (the dated file already exists). - Restart-safe: on startup it backs up immediately only if the newest existing backup is older than the interval, so frequent restarts don't spam the dir and a long gap is covered right away. Runs in a background goroutine for the life