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:
parent
fbfdc6877d
commit
885fae06ec
5 changed files with 42 additions and 79 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
11
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-<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
|
||||
|
|
|
|||
Loading…
Reference in a new issue