From 6c749ba591c588c30fcd6268503033f53a68d559 Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Wed, 25 Mar 2026 17:32:39 +0900 Subject: [PATCH] feat: add DB backup with consistent BoltDB snapshots Co-Authored-By: Claude Sonnet 4.6 --- store/backup.go | 40 ++++++++++++++++++++++++++ store/backup_test.go | 68 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 store/backup.go create mode 100644 store/backup_test.go diff --git a/store/backup.go b/store/backup.go new file mode 100644 index 0000000..f534741 --- /dev/null +++ b/store/backup.go @@ -0,0 +1,40 @@ +package store + +import ( + "fmt" + "os" + "path/filepath" + "time" + + bolt "go.etcd.io/bbolt" +) + +// Backup creates a consistent snapshot of the database in destDir. +// Returns the path to the backup file. +func (d *DB) Backup(destDir string) (string, error) { + if err := os.MkdirAll(destDir, 0755); err != nil { + return "", fmt.Errorf("create backup dir: %w", err) + } + + timestamp := time.Now().Format("20060102-150405") + filename := fmt.Sprintf("catacombs-%s.db", timestamp) + destPath := filepath.Join(destDir, filename) + + f, err := os.Create(destPath) + if err != nil { + return "", fmt.Errorf("create backup file: %w", err) + } + defer f.Close() + + // BoltDB View transaction provides a consistent snapshot + err = d.db.View(func(tx *bolt.Tx) error { + _, err := tx.WriteTo(f) + return err + }) + if err != nil { + os.Remove(destPath) + return "", fmt.Errorf("backup write: %w", err) + } + + return destPath, nil +} diff --git a/store/backup_test.go b/store/backup_test.go new file mode 100644 index 0000000..348b7e4 --- /dev/null +++ b/store/backup_test.go @@ -0,0 +1,68 @@ +package store + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestBackup(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "test.db") + db, err := Open(dbPath) + if err != nil { + t.Fatalf("failed to open db: %v", err) + } + defer db.Close() + + if err := db.SaveProfile("fp1", "player1"); err != nil { + t.Fatalf("failed to save profile: %v", err) + } + + backupDir := filepath.Join(tmpDir, "backups") + + backupPath, err := db.Backup(backupDir) + if err != nil { + t.Fatalf("backup failed: %v", err) + } + + if _, err := os.Stat(backupPath); os.IsNotExist(err) { + t.Fatal("backup file does not exist") + } + + base := filepath.Base(backupPath) + if !strings.HasPrefix(base, "catacombs-") || !strings.HasSuffix(base, ".db") { + t.Fatalf("unexpected backup filename: %s", base) + } + + backupDB, err := Open(backupPath) + if err != nil { + t.Fatalf("failed to open backup: %v", err) + } + defer backupDB.Close() + + name, err := backupDB.GetProfile("fp1") + if err != nil { + t.Fatalf("failed to read from backup: %v", err) + } + if name != "player1" { + t.Fatalf("expected player1, got %s", name) + } +} + +func TestBackupCreatesDir(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "test.db") + db, err := Open(dbPath) + if err != nil { + t.Fatalf("failed to open db: %v", err) + } + defer db.Close() + + backupDir := filepath.Join(tmpDir, "nested", "backups") + _, err = db.Backup(backupDir) + if err != nil { + t.Fatalf("backup with nested dir failed: %v", err) + } +}