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
# 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

View file

@ -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)

View file

@ -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)

View file

@ -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 {

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.
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-<UTC timestamp>.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_<YYYY_MM_DD>.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