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
133 changes: 133 additions & 0 deletions storage/backup_scheduler.go
Original file line number Diff line number Diff line change
@@ -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()
}
}
}
135 changes: 135 additions & 0 deletions storage/backup_scheduler_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading