Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions storage/backup_rotate.go
Original file line number Diff line number Diff line change
@@ -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
}
184 changes: 184 additions & 0 deletions storage/backup_rotate_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading