# AI Implementation Guide: Go Scheduled Backup Rotation (ZIP) This document is written **for an AI** to replicate the same backup approach in another Go project. It contains the exact requirements, design notes, and full module listings you can copy. ## Requirements (Behavioral) - Run backups on a daily schedule at a configured local time (default `00:00`). - At startup, if there is no backup for the current period, create it immediately. - Backup content must include: - Local SQLite DB file (e.g., `qfs.db`). - SQLite sidecars (`-wal`, `-shm`) if present. - Runtime config file (e.g., `config.yaml`) if present. - Backups must be ZIP archives named: - `qfs-backp-YYYY-MM-DD.zip` - Retention policy: - 7 daily, 4 weekly, 12 monthly, 10 yearly archives. - Keep backups in period-specific directories: - `/daily`, `/weekly`, `/monthly`, `/yearly`. - Prevent duplicate backups for the same period via a marker file. - Log success with the archive path, and log errors on failure. ## Configuration & Env - Config key: `backup.time` with format `HH:MM` in local time. Default: `00:00`. - Env overrides: - `QFS_BACKUP_DIR` — backup root directory. - `QFS_BACKUP_DISABLE` — disable backups (`1/true/yes`). ## Integration Steps (Minimal) 1. Add `BackupConfig` to your config struct. 2. Add a scheduler goroutine that: - On startup: runs backup immediately if needed. - Then sleeps until next configured time and runs daily. 3. Add the backup module (below). 4. Wire logs for success/failure. --- # Full Go Listings ## 1) Backup Module (Drop-in) Create: `internal/appstate/backup.go` ```go package appstate import ( "archive/zip" "encoding/json" "fmt" "io" "os" "path/filepath" "sort" "strings" "time" ) type backupPeriod struct { name string retention int key func(time.Time) string date func(time.Time) string } var backupPeriods = []backupPeriod{ { name: "daily", retention: 7, key: func(t time.Time) string { return t.Format("2006-01-02") }, date: func(t time.Time) string { return t.Format("2006-01-02") }, }, { name: "weekly", retention: 4, key: func(t time.Time) string { y, w := t.ISOWeek() return fmt.Sprintf("%04d-W%02d", y, w) }, date: func(t time.Time) string { return t.Format("2006-01-02") }, }, { name: "monthly", retention: 12, key: func(t time.Time) string { return t.Format("2006-01") }, date: func(t time.Time) string { return t.Format("2006-01-02") }, }, { name: "yearly", retention: 10, key: func(t time.Time) string { return t.Format("2006") }, date: func(t time.Time) string { return t.Format("2006-01-02") }, }, } const ( envBackupDisable = "QFS_BACKUP_DISABLE" envBackupDir = "QFS_BACKUP_DIR" ) var backupNow = time.Now // EnsureRotatingLocalBackup creates or refreshes daily/weekly/monthly/yearly backups // for the local database and config. It keeps a limited number per period. func EnsureRotatingLocalBackup(dbPath, configPath string) ([]string, error) { if isBackupDisabled() { return nil, nil } if dbPath == "" { return nil, nil } if _, err := os.Stat(dbPath); err != nil { if os.IsNotExist(err) { return nil, nil } return nil, fmt.Errorf("stat db: %w", err) } root := resolveBackupRoot(dbPath) now := backupNow() created := make([]string, 0) for _, period := range backupPeriods { newFiles, err := ensurePeriodBackup(root, period, now, dbPath, configPath) if err != nil { return created, err } if len(newFiles) > 0 { created = append(created, newFiles...) } } return created, nil } func resolveBackupRoot(dbPath string) string { if fromEnv := strings.TrimSpace(os.Getenv(envBackupDir)); fromEnv != "" { return filepath.Clean(fromEnv) } return filepath.Join(filepath.Dir(dbPath), "backups") } func isBackupDisabled() bool { val := strings.ToLower(strings.TrimSpace(os.Getenv(envBackupDisable))) return val == "1" || val == "true" || val == "yes" } func ensurePeriodBackup(root string, period backupPeriod, now time.Time, dbPath, configPath string) ([]string, error) { key := period.key(now) periodDir := filepath.Join(root, period.name) if err := os.MkdirAll(periodDir, 0755); err != nil { return nil, fmt.Errorf("create %s backup dir: %w", period.name, err) } if hasBackupForKey(periodDir, key) { return nil, nil } archiveName := fmt.Sprintf("qfs-backp-%s.zip", period.date(now)) archivePath := filepath.Join(periodDir, archiveName) if err := createBackupArchive(archivePath, dbPath, configPath); err != nil { return nil, fmt.Errorf("create %s backup archive: %w", period.name, err) } if err := writePeriodMarker(periodDir, key); err != nil { return []string{archivePath}, err } if err := pruneOldBackups(periodDir, period.retention); err != nil { return []string{archivePath}, err } return []string{archivePath}, nil } func hasBackupForKey(periodDir, key string) bool { marker := periodMarker{Key: ""} data, err := os.ReadFile(periodMarkerPath(periodDir)) if err != nil { return false } if err := json.Unmarshal(data, &marker); err != nil { return false } return marker.Key == key } type periodMarker struct { Key string `json:"key"` } func periodMarkerPath(periodDir string) string { return filepath.Join(periodDir, ".period.json") } func writePeriodMarker(periodDir, key string) error { data, err := json.MarshalIndent(periodMarker{Key: key}, "", " ") if err != nil { return err } return os.WriteFile(periodMarkerPath(periodDir), data, 0644) } func pruneOldBackups(periodDir string, keep int) error { entries, err := os.ReadDir(periodDir) if err != nil { return fmt.Errorf("read backups dir: %w", err) } files := make([]os.DirEntry, 0, len(entries)) for _, entry := range entries { if entry.IsDir() { continue } if strings.HasSuffix(entry.Name(), ".zip") { files = append(files, entry) } } if len(files) <= keep { return nil } sort.Slice(files, func(i, j int) bool { infoI, errI := files[i].Info() infoJ, errJ := files[j].Info() if errI != nil || errJ != nil { return files[i].Name() < files[j].Name() } return infoI.ModTime().Before(infoJ.ModTime()) }) for i := 0; i < len(files)-keep; i++ { path := filepath.Join(periodDir, files[i].Name()) if err := os.Remove(path); err != nil { return fmt.Errorf("remove old backup %s: %w", path, err) } } return nil } func createBackupArchive(destPath, dbPath, configPath string) error { file, err := os.Create(destPath) if err != nil { return err } defer file.Close() zipWriter := zip.NewWriter(file) if err := addZipFile(zipWriter, dbPath); err != nil { _ = zipWriter.Close() return err } _ = addZipOptionalFile(zipWriter, dbPath+"-wal") _ = addZipOptionalFile(zipWriter, dbPath+"-shm") if strings.TrimSpace(configPath) != "" { _ = addZipOptionalFile(zipWriter, configPath) } if err := zipWriter.Close(); err != nil { return err } return file.Sync() } func addZipOptionalFile(writer *zip.Writer, path string) error { if _, err := os.Stat(path); err != nil { return nil } return addZipFile(writer, path) } func addZipFile(writer *zip.Writer, path string) error { in, err := os.Open(path) if err != nil { return err } defer in.Close() info, err := in.Stat() if err != nil { return err } header, err := zip.FileInfoHeader(info) if err != nil { return err } header.Name = filepath.Base(path) header.Method = zip.Deflate out, err := writer.CreateHeader(header) if err != nil { return err } _, err = io.Copy(out, in) return err } ``` --- ## 2) Scheduler Hook (Main) Add this to your `main.go` (or equivalent). This schedules daily backups and logs success. ```go func startBackupScheduler(ctx context.Context, cfg *config.Config, dbPath, configPath string) { if cfg == nil { return } hour, minute, err := parseBackupTime(cfg.Backup.Time) if err != nil { slog.Warn("invalid backup time; using 00:00", "value", cfg.Backup.Time, "error", err) hour = 0 minute = 0 } // Startup check: if no backup exists for current periods, create now. if created, backupErr := appstate.EnsureRotatingLocalBackup(dbPath, configPath); backupErr != nil { slog.Error("local backup failed", "error", backupErr) } else if len(created) > 0 { for _, path := range created { slog.Info("local backup completed", "archive", path) } } for { next := nextBackupTime(time.Now(), hour, minute) timer := time.NewTimer(time.Until(next)) select { case <-ctx.Done(): timer.Stop() return case <-timer.C: start := time.Now() created, backupErr := appstate.EnsureRotatingLocalBackup(dbPath, configPath) duration := time.Since(start) if backupErr != nil { slog.Error("local backup failed", "error", backupErr, "duration", duration) } else { for _, path := range created { slog.Info("local backup completed", "archive", path, "duration", duration) } } } } } func parseBackupTime(value string) (int, int, error) { if strings.TrimSpace(value) == "" { return 0, 0, fmt.Errorf("empty backup time") } parsed, err := time.Parse("15:04", value) if err != nil { return 0, 0, err } return parsed.Hour(), parsed.Minute(), nil } func nextBackupTime(now time.Time, hour, minute int) time.Time { location := now.Location() target := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, location) if !now.Before(target) { target = target.Add(24 * time.Hour) } return target } ``` --- ## 3) Config Struct (Minimal) Add to config: ```go type BackupConfig struct { Time string `yaml:"time"` } ``` Default: ```go if c.Backup.Time == "" { c.Backup.Time = "00:00" } ``` --- ## Notes for Replication - Keep `backup.time` in local time. Do **not** parse with timezone offsets unless required. - The `.period.json` marker is what prevents duplicate backups within the same period. - The archive file name only contains the date. Uniqueness is ensured by per-period directories and the period marker. - If you change naming or retention, update both the file naming and prune logic together.