Backups: ROOT/dbbackup, hsa_sqlite_backup_YYYY_MM_DD.db naming

- Backup dir default ./data/dbbackup; files named hsa_sqlite_backup_<YYYY_MM_DD>.db
  (one per day; a same-day re-run is a no-op since the dated file exists).
- Simplify the scheduler: attempt on start, then tick every BACKUP_INTERVAL.
  Per-day idempotency makes restarts and sub-day intervals settle at one/day —
  removes the date-parsing untilNext/newestBackup logic (and its busy-loop edge).
- BACKUP_INTERVAL remains the frequency knob (Go duration; 0 disables).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jean-Michel Tremblay 2026-06-19 07:34:31 -04:00
parent fbfdc6877d
commit 885fae06ec
5 changed files with 42 additions and 79 deletions

View file

@ -34,8 +34,10 @@ STORAGE_DIR=./data
MAX_UPLOAD_MB=32 MAX_UPLOAD_MB=32
# Scheduled metadata-only DB backups (no image blobs). On by default, weekly. # 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. # Files are named hsa_sqlite_backup_YYYY_MM_DD.db (one per day).
BACKUP_DIR=./data/backups # 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_INTERVAL=168h
BACKUP_KEEP=8 BACKUP_KEEP=8

View file

@ -22,57 +22,46 @@ type Snapshotter interface {
} }
const ( const (
prefix = "hsa-backup-" prefix = "hsa_sqlite_backup_"
suffix = ".db" 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 // Schedule runs metadata-only backups into dir, attempting one on start and then
// recent `keep`. It blocks until ctx is cancelled, so run it in a goroutine. // 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 // Backups are idempotent per day (date-only filenames): a write whose dated file
// exists — so frequent restarts don't spam the directory, and a gap longer than // already exists is a no-op, so frequent restarts — or intervals shorter than a
// the interval is covered right away. `now` is injected for testability (pass // day — never duplicate or spam, they just settle at one backup per day.
// time.Now).
func Schedule(ctx context.Context, s Snapshotter, dir string, every time.Duration, keep int, now func() time.Time) { 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 { if err := os.MkdirAll(dir, 0o700); err != nil {
log.Printf("backup: cannot create %s: %v (backups disabled)", dir, err) log.Printf("backup: cannot create %s: %v (backups disabled)", dir, err)
return return
} }
for { 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 { select {
case <-ctx.Done(): case <-ctx.Done():
timer.Stop() timer.Stop()
return return
case <-timer.C: 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 { func writeBackup(s Snapshotter, dir string, now time.Time) error {
name := prefix + now.UTC().Format(stamp) + suffix 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) return fmt.Errorf("write %s: %w", name, err)
} }
log.Printf("backup: wrote %s", name) log.Printf("backup: wrote %s", name)
@ -96,19 +85,6 @@ func backupFiles(dir string) []string {
return 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`. // prune deletes the oldest backups beyond the most recent `keep`.
func prune(dir string, keep int) error { func prune(dir string, keep int) error {
names := backupFiles(dir) names := backupFiles(dir)

View file

@ -18,7 +18,7 @@ func (f *fakeSnap) SnapshotTo(dest string, includeBlobs bool) error {
return os.WriteFile(dest, []byte("snap"), 0o600) return os.WriteFile(dest, []byte("snap"), 0o600)
} }
func TestWriteBackupAndNewest(t *testing.T) { func TestWriteBackup_NamesAndSameDayNoOp(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
s := &fakeSnap{} s := &fakeSnap{}
now := time.Date(2026, 6, 10, 9, 0, 0, 0, time.UTC) now := time.Date(2026, 6, 10, 9, 0, 0, 0, time.UTC)
@ -27,35 +27,19 @@ func TestWriteBackupAndNewest(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
files := backupFiles(dir) 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) 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)
} }
} if files := backupFiles(dir); len(files) != 1 {
t.Errorf("same-day re-run produced %v, want 1 file", files)
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 s.calls != 1 {
// A fresh backup → wait nearly the full interval. t.Errorf("SnapshotTo called %d times, want 1 (second was a no-op)", s.calls)
_ = 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)
} }
} }
@ -74,7 +58,7 @@ func TestPruneKeepsMostRecent(t *testing.T) {
t.Fatalf("after prune: %v", files) t.Fatalf("after prune: %v", files)
} }
// The two newest (Jan 04, Jan 05) survive. // 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) t.Errorf("wrong files kept: %v", files)
} }
} }
@ -91,7 +75,7 @@ func TestScheduleRunsOnceThenStops(t *testing.T) {
// Wait for the immediate first backup to land. // Wait for the immediate first backup to land.
deadline := time.Now().Add(2 * time.Second) deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) { 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 return
} }
time.Sleep(10 * time.Millisecond) time.Sleep(10 * time.Millisecond)

View file

@ -93,7 +93,7 @@ func Load() (Config, error) {
// Scheduled metadata-only DB backups (on by default, weekly). Set // Scheduled metadata-only DB backups (on by default, weekly). Set
// BACKUP_INTERVAL=0 to disable. // 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 c.BackupEvery = 7 * 24 * time.Hour
if v := strings.TrimSpace(os.Getenv("BACKUP_INTERVAL")); v != "" { if v := strings.TrimSpace(os.Getenv("BACKUP_INTERVAL")); v != "" {
if d, err := time.ParseDuration(v); err == nil { if d, err := time.ParseDuration(v); err == nil {

11
spec.md
View file

@ -317,11 +317,12 @@ external cron job.
backup excludes image blobs from BOTH receipts and attachments, so it is tiny. 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 on-disk files and the full-blob export (/export/db) remain the source for
the images themselves. the images themselves.
- On by default, weekly. Config: BACKUP_DIR (default ./data/backups), - On by default, weekly. Config: BACKUP_DIR (default ./data/dbbackup),
BACKUP_INTERVAL (Go duration, default 168h; set 0 to disable), BACKUP_KEEP BACKUP_INTERVAL (Go duration sets the frequency, default 168h; set 0 to
(most-recent copies to retain, default 8). disable), BACKUP_KEEP (most-recent copies to retain, default 8).
- Files are named hsa-backup-<UTC timestamp>.db so they sort chronologically; - Files are named hsa_sqlite_backup_<YYYY_MM_DD>.db — one per day, sorting
older copies beyond BACKUP_KEEP are pruned. 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 - 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 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 a long gap is covered right away. Runs in a background goroutine for the life