From 0b6a6ad0aa8bdc394bf08cde362be761e1860be3 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 15 Aug 2026 18:23:06 +0530 Subject: [PATCH] feat(storage): periodic backup scheduler with rotation - Store.ScheduleBackups(dir, interval, keep, maxAge) returns a BackupScheduler writing timestamped VACUUM INTO snapshots with unique nano suffixes (VACUUM INTO refuses existing targets) - Start runs the loop: immediate first snapshot, then per-interval best-effort snapshots; each tick rotates the directory via RotateBackups - Stop drains the run loop; Stop without Start and repeated Start/Stop are safe no-ops - Tests: validation, RunNow+rotation with restore-readback, start/stop lifecycle, stop-without-start (race-clean) --- storage/backup_scheduler.go | 133 ++++++++++++++++++++++++++++++ storage/backup_scheduler_test.go | 135 +++++++++++++++++++++++++++++++ 2 files changed, 268 insertions(+) create mode 100644 storage/backup_scheduler.go create mode 100644 storage/backup_scheduler_test.go diff --git a/storage/backup_scheduler.go b/storage/backup_scheduler.go new file mode 100644 index 0000000..2b5f4f9 --- /dev/null +++ b/storage/backup_scheduler.go @@ -0,0 +1,133 @@ +package storage + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sync" + "time" +) + +// BackupScheduler produces periodic VACUUM INTO snapshots of the database +// into dir and rotates them with Store.RotateBackups. Start a scheduler +// once per store; Stop releases its goroutine. Schedulers are safe to run +// concurrently with reads and writes — VACUUM INTO takes a brief WAL read +// lock but does not block writers beyond it. +type BackupScheduler struct { + store *Store + dir string + interval time.Duration + keep int + maxAge time.Duration + + mu sync.Mutex // guards started/stopped + started bool + stopped bool + stop chan struct{} + stoppedW chan struct{} // closed when the run loop returns +} + +// ScheduleBackups registers a scheduler that snapshots the store into dir +// every interval, keeping at most keep backups and pruning backups older +// than maxAge (0 = no age limit, 0 keep = no count limit). The directory is +// created on first use with owner-only permissions. The scheduler does not +// start until Start is called; RunNow takes an immediate snapshot. +// +// interval must be positive. A negative keep or maxAge is rejected. +func (s *Store) ScheduleBackups(dir string, interval time.Duration, keep int, maxAge time.Duration) (*BackupScheduler, error) { + if interval <= 0 { + return nil, fmt.Errorf("backup interval must be positive") + } + if keep < 0 || maxAge < 0 { + return nil, fmt.Errorf("backup retention must not be negative") + } + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, fmt.Errorf("create backup directory: %w", err) + } + return &BackupScheduler{ + store: s, + dir: dir, + interval: interval, + keep: keep, + maxAge: maxAge, + stop: make(chan struct{}), + stoppedW: make(chan struct{}), + }, nil +} + +// Start launches the periodic backup loop. Subsequent calls are no-ops. +func (b *BackupScheduler) Start() { + b.mu.Lock() + if b.started || b.stopped { + b.mu.Unlock() + return + } + b.started = true + b.mu.Unlock() + + go b.run() +} + +// Stop halts the scheduler and waits for the run loop to exit. If Start was +// never called, Stop simply marks the scheduler stopped. Safe to call +// multiple times. +func (b *BackupScheduler) Stop() { + b.mu.Lock() + if b.stopped { + b.mu.Unlock() + return + } + b.stopped = true + done := b.stoppedW + if b.started { + close(b.stop) + } else { + done = nil // no run loop to drain + } + b.mu.Unlock() + + if done != nil { + <-done + } +} + +// RunNow takes an immediate snapshot and rotates the directory. Names are +// UTC-timestamp based with a nano-second suffix so snapshots taken within +// the same second never collide (VACUUM INTO refuses existing targets). +func (b *BackupScheduler) RunNow(ctx context.Context) error { + now := time.Now().UTC() + name := filepath.Join(b.dir, + fmt.Sprintf("yaad-%s-%d.db", now.Format("20060102T150405Z"), now.UnixNano())) + if err := b.store.Backup(ctx, name); err != nil { + return err + } + return b.store.RotateBackups(ctx, b.dir, b.keep, b.maxAge) +} + +// run takes an initial snapshot, then ticks every interval until Stop is +// called. Each snapshot is best-effort: a failed attempt is skipped rather +// than retry-looping; the next tick tries again. +func (b *BackupScheduler) run() { + defer close(b.stoppedW) + + // Snapshot once immediately so a short-lived host still leaves a + // backup behind; rotation prunes duplicates from frequent restarts. + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + _ = b.RunNow(ctx) + cancel() + + ticker := time.NewTicker(b.interval) + defer ticker.Stop() + + for { + select { + case <-b.stop: + return + case <-ticker.C: + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + _ = b.RunNow(ctx) + cancel() + } + } +} diff --git a/storage/backup_scheduler_test.go b/storage/backup_scheduler_test.go new file mode 100644 index 0000000..7dfbe0a --- /dev/null +++ b/storage/backup_scheduler_test.go @@ -0,0 +1,135 @@ +package storage + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" +) + +func TestScheduleBackupsValidation(t *testing.T) { + s, cleanup := setupStore(t) + defer cleanup() + + dir := t.TempDir() + cases := []struct { + name string + interval time.Duration + keep int + maxAge time.Duration + wantErr bool + }{ + {"valid", time.Hour, 3, time.Hour, false}, + {"zero-interval", 0, 3, 0, true}, + {"negative-interval", -time.Minute, 3, 0, true}, + {"negative-keep", time.Hour, -1, 0, true}, + {"negative-maxage", time.Hour, 0, -time.Hour, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := s.ScheduleBackups(dir, tc.interval, tc.keep, tc.maxAge) + if (err != nil) != tc.wantErr { + t.Fatalf("ScheduleBackups err = %v, wantErr = %v", err, tc.wantErr) + } + }) + } +} + +func TestSchedulerRunNowAndRotation(t *testing.T) { + s, cleanup := setupStore(t) + defer cleanup() + ctx := context.Background() + + if err := s.CreateNode(ctx, &Node{ + ID: "sched-node", Type: "convention", Content: "schedule me", + ContentHash: "h-sched", Scope: "project", Project: "test", + }); err != nil { + t.Fatal(err) + } + + dir := t.TempDir() + sched, err := s.ScheduleBackups(dir, time.Hour, 2, 0) + if err != nil { + t.Fatal(err) + } + + for i := 0; i < 3; i++ { + if err := sched.RunNow(ctx); err != nil { + t.Fatalf("RunNow #%d: %v", i, err) + } + } + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + if len(entries) != 2 { + t.Fatalf("expected rotation to keep 2 backups, got %d", len(entries)) + } + + // Each backup must be a consistent, openable snapshot. + for _, e := range entries { + restore, err := NewStore(filepath.Join(dir, e.Name())) + if err != nil { + t.Fatalf("open backup %s: %v", e.Name(), err) + } + if _, err := restore.GetNode(ctx, "sched-node"); err != nil { + t.Errorf("backup %s missing sched-node: %v", e.Name(), err) + } + _ = restore.Close() + } +} + +func TestSchedulerStartStop(t *testing.T) { + s, cleanup := setupStore(t) + defer cleanup() + dir := t.TempDir() + + sched, err := s.ScheduleBackups(dir, 20*time.Millisecond, 5, 0) + if err != nil { + t.Fatal(err) + } + sched.Start() + + // Several ticks produce at least one backup. + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + entries, _ := os.ReadDir(dir) + if len(entries) > 0 { + break + } + time.Sleep(10 * time.Millisecond) + } + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + if len(entries) == 0 { + t.Fatal("expected at least one backup after Start") + } + + sched.Stop() + // Stopping twice is a no-op, and stopping never panics. + sched.Stop() +} + +func TestSchedulerStopWithoutStart(t *testing.T) { + s, cleanup := setupStore(t) + defer cleanup() + + sched, err := s.ScheduleBackups(t.TempDir(), time.Hour, 1, 0) + if err != nil { + t.Fatal(err) + } + sched.Stop() // must not deadlock or panic + sched.Stop() + sched.Start() // Start after Stop is a no-op +} + +func TestScheduleBackupsEmptyDir(t *testing.T) { + s, cleanup := setupStore(t) + defer cleanup() + if _, err := s.ScheduleBackups("", time.Hour, 1, 0); err == nil { + t.Fatal("expected error for empty backup directory") + } +}