diff --git a/storage/backup_rotate.go b/storage/backup_rotate.go new file mode 100644 index 0000000..d84e947 --- /dev/null +++ b/storage/backup_rotate.go @@ -0,0 +1,77 @@ +package storage + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +// defaultTmpMaxAge bounds how long aborted Backup temp files may linger +// before RotateBackups sweeps them. +const defaultTmpMaxAge = 24 * time.Hour + +// RotateBackups prunes the backup directory after a Backup so it does not +// grow without bound. It keeps the newest `keep` backup files (0 = no count +// limit), then deletes any remaining backups older than `maxAge` (0 = no +// age limit). The newest backup is never deleted by age alone — rotation +// must not leave a healthy database with zero backups if it was simply idle. +// +// Only regular files directly inside dir are considered; stale "*.tmp" files +// left behind by aborted Backup writes are removed after 24h. +func (s *Store) RotateBackups(ctx context.Context, dir string, keep int, maxAge time.Duration) error { + if dir == "" { + return fmt.Errorf("backup directory must not be empty") + } + entries, err := os.ReadDir(dir) + if err != nil { + return fmt.Errorf("read backup directory: %w", err) + } + + type backup struct { + path string + modTime time.Time + } + backups := make([]backup, 0, len(entries)) + for _, e := range entries { + if !e.Type().IsRegular() { + continue + } + name := e.Name() + info, err := e.Info() + if err != nil { + continue // disappearing mid-scan; nothing to do + } + path := filepath.Join(dir, name) + // Sweep abandoned VACUUM INTO temp files from failed backups. + if strings.HasSuffix(name, ".tmp") { + if time.Since(info.ModTime()) > defaultTmpMaxAge { + _ = os.Remove(path) + } + continue + } + backups = append(backups, backup{path: path, modTime: info.ModTime()}) + } + if len(backups) == 0 { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + sort.Slice(backups, func(i, j int) bool { return backups[i].modTime.After(backups[j].modTime) }) + + for i, b := range backups { + tooMany := keep > 0 && i >= keep + tooOld := maxAge > 0 && time.Since(b.modTime) > maxAge && i > 0 + if tooMany || tooOld { + _ = os.Remove(b.path) + } + } + return nil +} diff --git a/storage/backup_rotate_test.go b/storage/backup_rotate_test.go new file mode 100644 index 0000000..cef979c --- /dev/null +++ b/storage/backup_rotate_test.go @@ -0,0 +1,184 @@ +package storage + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + "time" +) + +func writeBackupFile(t *testing.T, dir string, name string, age time.Duration) string { + t.Helper() + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte("backup"+name), 0o600); err != nil { + t.Fatal(err) + } + if age > 0 { + older := time.Now().Add(-age) + if err := os.Chtimes(path, older, older); err != nil { + t.Fatal(err) + } + } + return path +} + +func exists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func TestRotateBackupsCount(t *testing.T) { + s, cleanup := setupStore(t) + defer cleanup() + + dir := t.TempDir() + old1 := writeBackupFile(t, dir, "yaad-1.db", 3*time.Hour) + old2 := writeBackupFile(t, dir, "yaad-2.db", 2*time.Hour) + old3 := writeBackupFile(t, dir, "yaad-3.db", 1*time.Hour) + newest := writeBackupFile(t, dir, "yaad-4.db", 0) + + if err := s.RotateBackups(context.Background(), dir, 2, 0); err != nil { + t.Fatalf("RotateBackups: %v", err) + } + if !exists(newest) || !exists(old3) { + t.Errorf("newest two backups must survive: %v %v", exists(newest), exists(old3)) + } + if exists(old1) || exists(old2) { + t.Error("excess backups should be removed") + } +} + +func TestRotateBackupsAge(t *testing.T) { + s, cleanup := setupStore(t) + defer cleanup() + + dir := t.TempDir() + stale := writeBackupFile(t, dir, "yaad-old.db", 48*time.Hour) + recent := writeBackupFile(t, dir, "yaad-mid.db", 30*time.Minute) + newest := writeBackupFile(t, dir, "yaad-new.db", 0) + + if err := s.RotateBackups(context.Background(), dir, 0, time.Hour); err != nil { + t.Fatalf("RotateBackups: %v", err) + } + if exists(stale) { + t.Error("backup older than maxAge should be removed") + } + if !exists(recent) || !exists(newest) { + t.Error("recent backups must survive age-based rotation") + } +} + +func TestRotateBackupsNewestNeverDeletedByAge(t *testing.T) { + s, cleanup := setupStore(t) + defer cleanup() + + dir := t.TempDir() + // A single old backup must never be deleted by age: the store must + // not end up with zero backups just because it has been idle. + solo := writeBackupFile(t, dir, "yaad-solo.db", 72*time.Hour) + + if err := s.RotateBackups(context.Background(), dir, 0, time.Hour); err != nil { + t.Fatalf("RotateBackups: %v", err) + } + if !exists(solo) { + t.Error("the newest backup must survive age-based rotation") + } +} + +func TestRotateBackupsNoLimits(t *testing.T) { + s, cleanup := setupStore(t) + defer cleanup() + + dir := t.TempDir() + for i := 0; i < 5; i++ { + writeBackupFile(t, dir, fmt.Sprintf("yaad-%d.db", i), time.Duration(i)*time.Hour) + } + if err := s.RotateBackups(context.Background(), dir, 0, 0); err != nil { + t.Fatalf("RotateBackups: %v", err) + } + for i := 0; i < 5; i++ { + if !exists(filepath.Join(dir, fmt.Sprintf("yaad-%d.db", i))) { + t.Fatalf("backup %d should survive with no limits", i) + } + } +} + +func TestRotateBackupsSweepsStaleTmp(t *testing.T) { + s, cleanup := setupStore(t) + defer cleanup() + + dir := t.TempDir() + staleTmp := writeBackupFile(t, dir, "yaad.db.123.tmp", 48*time.Hour) // aborted Backup left this + freshTmp := writeBackupFile(t, dir, "yaad.db.456.tmp", 1*time.Minute) + + if err := s.RotateBackups(context.Background(), dir, 0, 0); err != nil { + t.Fatalf("RotateBackups: %v", err) + } + if exists(staleTmp) { + t.Error("stale .tmp from an aborted backup should be swept") + } + if !exists(freshTmp) { + t.Error("fresh .tmp must be left alone (its backup may still be writing)") + } +} + +func TestRotateBackupsEmptyDir(t *testing.T) { + s, cleanup := setupStore(t) + defer cleanup() + if err := s.RotateBackups(context.Background(), t.TempDir(), 2, time.Hour); err != nil { + t.Fatalf("RotateBackups on empty dir: %v", err) + } + if err := s.RotateBackups(context.Background(), "", 2, time.Hour); err == nil { + t.Error("expected error for empty backup directory") + } +} + +// TestBackupRotationEndToEnd runs Backup a few times with rotation, keeping +// the configured window. +func TestBackupRotationEndToEnd(t *testing.T) { + s, cleanup := setupStore(t) + defer cleanup() + ctx := context.Background() + + if err := s.CreateNode(ctx, &Node{ + ID: "rot-node", Type: "convention", Content: "rotate me", + ContentHash: "h-rotate", Scope: "project", Project: "test", + }); err != nil { + t.Fatal(err) + } + + dir := t.TempDir() + for i := 0; i < 6; i++ { + name := filepath.Join(dir, fmt.Sprintf("yaad-%d.db", i)) + if err := s.Backup(ctx, name); err != nil { + t.Fatalf("Backup #%d: %v", i, err) + } + // Ensure distinct mtimes so ordering is unambiguous. + mt := time.Now().Add(time.Duration(i) * time.Second) + if err := os.Chtimes(name, mt, mt); err != nil { + t.Fatal(err) + } + if err := s.RotateBackups(ctx, dir, 3, 0); err != nil { + t.Fatalf("RotateBackups #%d: %v", i, err) + } + } + + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + var names []string + for _, e := range entries { + names = append(names, e.Name()) + } + if len(names) != 3 { + t.Fatalf("expected 3 backups kept, got %d: %v", len(names), names) + } + for _, want := range []string{"yaad-3.db", "yaad-4.db", "yaad-5.db"} { + if !exists(filepath.Join(dir, want)) { + t.Errorf("expected newest backup %s to be kept: %v", want, names) + } + } +}