Document backup implementation guide

This commit is contained in:
Mikhail Chusavitin
2026-02-11 19:50:35 +03:00
parent a1edca3be9
commit 01f21fa5ac

View File

@@ -1,77 +1,413 @@
# Backup Policy (QuoteForge)
# AI Implementation Guide: Go Scheduled Backup Rotation (ZIP)
## Overview
QuoteForge performs automatic backups of local runtime data on a daily schedule. Backups are stored as ZIP archives and rotated per period (daily/weekly/monthly/yearly).
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.
The policy is designed to be easily replicated across installations and user environments without additional dependencies.
## 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:
- `<backup root>/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.
## What Gets Backed Up
Each backup archive contains:
- Local SQLite database file (`qfs.db`)
- SQLite sidecar files (`qfs.db-wal`, `qfs.db-shm`) if present
- Runtime config file (`config.yaml`) if present
## 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`).
## Schedule
Backups run once per day at a configured time.
- Config key: `backup.time`
- Default: `00:00`
- Format: `HH:MM` (24-hour local time)
## 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.
## Storage Location
Default location is next to the local DB:
```
<db dir>/backups/
```
Period-specific subdirectories:
```
backups/daily
backups/weekly
backups/monthly
backups/yearly
---
# 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
}
```
Optional override:
- `QFS_BACKUP_DIR` — absolute or relative path to the backup root
---
## Naming Convention
Backup files are ZIP archives named by creation date:
```
qfs-backp-YYYY-MM-DD.zip
```
Each period keeps its own copy under its directory.
## 2) Scheduler Hook (Main)
Add this to your `main.go` (or equivalent). This schedules daily backups and logs success.
## Retention
Rotation keeps a fixed number of archives per period:
- Daily: 7 archives
- Weekly: 4 archives
- Monthly: 12 archives
- Yearly: 10 archives
```go
func startBackupScheduler(ctx context.Context, cfg *config.Config, dbPath, configPath string) {
if cfg == nil {
return
}
Older archives beyond these limits are deleted automatically.
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
}
## Period Dedupe
A marker file stored inside each period directory tracks the last backup key to avoid duplicate backups within the same period.
- File: `.period.json`
- Content: `{ "key": "<period-key>" }`
// 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)
}
}
## Logs
Successful backup creation is logged with the archive path:
```
local backup completed archive=/path/to/.../qfs-backp-YYYY-MM-DD.zip duration=...
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
}
```
Failures are logged with:
```
local backup failed error=... duration=...
---
## 3) Config Struct (Minimal)
Add to config:
```go
type BackupConfig struct {
Time string `yaml:"time"`
}
```
## Disable Backups
Set environment variable:
Default:
```go
if c.Backup.Time == "" {
c.Backup.Time = "00:00"
}
```
QFS_BACKUP_DISABLE=1
```
Accepted values: `1`, `true`, `yes` (case-insensitive).
## Notes
- Backups are performed on startup if the current period has no backup.
- All paths are resolved relative to runtime DB/config paths.
---
## 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.