Add scheduled metadata-only DB backups (spec item 11)

- internal/backup: weekly (configurable) snapshots via the existing VACUUM INTO
  + blob-strip path; restart-safe (only backs up if newest is older than the
  interval), retains BACKUP_KEEP most recent, prunes the rest. Runs in a
  background goroutine; failures are logged, never fatal.
- Config: BACKUP_DIR (./data/backups), BACKUP_INTERVAL (168h; 0 disables),
  BACKUP_KEEP (8).
- Fix: blob-strip now clears attachments.image_data too, not just receipts.
- Fix: STORAGE_DIR is the storage ROOT (default ./data) — receipts/ and
  attachments/ live under it; corrects the doubled-nesting from item 10.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jean-Michel Tremblay 2026-06-19 07:12:50 -04:00
parent 04d6e8177d
commit fbfdc6877d
8 changed files with 317 additions and 6 deletions

View file

@ -25,12 +25,20 @@ LISTEN_ADDR=:8080
# Storage (defaults shown; relative paths are resolved from the app's working dir).
# For the LXC deployment use absolute paths under /var/lib/hsa.
# STORAGE_DIR is the storage ROOT — receipt files go under <STORAGE_DIR>/receipts/
# <year>/ and attachments under <STORAGE_DIR>/attachments/<year>/.
DB_PATH=./data/hsa.db
STORAGE_DIR=./data/receipts
STORAGE_DIR=./data
# Max upload size in megabytes (reject larger). Camera photos can be ~10MB.
MAX_UPLOAD_MB=32
# Scheduled metadata-only DB backups (no image blobs). On by default, weekly.
# BACKUP_INTERVAL accepts a Go duration (e.g. 168h, 24h); set 0 to disable.
BACKUP_DIR=./data/backups
BACKUP_INTERVAL=168h
BACKUP_KEEP=8
# People + categories catalog (seeded into the DB on startup).
CONFIG_PATH=./config.json

View file

@ -6,7 +6,9 @@ import (
"net/http"
"os"
"path/filepath"
"time"
"maisym.com/hsa/internal/backup"
"maisym.com/hsa/internal/classify"
"maisym.com/hsa/internal/config"
"maisym.com/hsa/internal/storage"
@ -63,6 +65,12 @@ func main() {
log.Fatalf("server init: %v", err)
}
// Scheduled metadata-only DB backups (best-effort, runs for the process life).
if cfg.BackupEvery > 0 {
go backup.Schedule(context.Background(), store, cfg.BackupDir, cfg.BackupEvery, cfg.BackupKeep, time.Now)
log.Printf("DB backups: every %s into %s (keep %d, metadata-only)", cfg.BackupEvery, cfg.BackupDir, cfg.BackupKeep)
}
log.Printf("HSA listening on %s (issuer %s)", cfg.ListenAddr, cfg.IssuerURL)
if err := http.ListenAndServe(cfg.ListenAddr, srv.Routes()); err != nil {
log.Fatalf("listen: %v", err)

124
internal/backup/backup.go Normal file
View file

@ -0,0 +1,124 @@
// Package backup writes periodic metadata-only snapshots of the database to a
// local directory, on a schedule, so a fresh copy of the receipt metadata always
// survives even if the live DB is lost. Blobs (receipt + attachment images) are
// excluded to keep the backups small — the on-disk files and the full-blob export
// remain the source for the images themselves.
package backup
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// Snapshotter writes a point-in-time DB copy. *storage.Store satisfies this.
type Snapshotter interface {
SnapshotTo(destPath string, includeBlobs bool) error
}
const (
prefix = "hsa-backup-"
suffix = ".db"
stamp = "2006-01-02-150405"
)
// Schedule runs metadata-only backups into dir every `every`, keeping the most
// recent `keep`. It blocks until ctx is cancelled, so run it in a goroutine.
//
// On start it backs up immediately unless a backup younger than `every` already
// exists — so frequent restarts don't spam the directory, and a gap longer than
// the interval is covered right away. `now` is injected for testability (pass
// time.Now).
func Schedule(ctx context.Context, s Snapshotter, dir string, every time.Duration, keep int, now func() time.Time) {
if err := os.MkdirAll(dir, 0o700); err != nil {
log.Printf("backup: cannot create %s: %v (backups disabled)", dir, err)
return
}
for {
timer := time.NewTimer(untilNext(dir, every, now()))
select {
case <-ctx.Done():
timer.Stop()
return
case <-timer.C:
}
if err := writeBackup(s, dir, now()); err != nil {
log.Printf("backup: %v", err)
continue
}
if err := prune(dir, keep); err != nil {
log.Printf("backup prune: %v", err)
}
}
}
// untilNext is how long to wait before the next backup: zero if none exists or the
// newest is already older than `every`, otherwise the remaining time.
func untilNext(dir string, every time.Duration, now time.Time) time.Duration {
newest := newestBackup(dir)
if newest.IsZero() {
return 0
}
if elapsed := now.Sub(newest); elapsed < every {
return every - elapsed
}
return 0
}
func writeBackup(s Snapshotter, dir string, now time.Time) error {
name := prefix + now.UTC().Format(stamp) + suffix
if err := s.SnapshotTo(filepath.Join(dir, name), false); err != nil {
return fmt.Errorf("write %s: %w", name, err)
}
log.Printf("backup: wrote %s", name)
return nil
}
// backupFiles returns the backup filenames in chronological order (the timestamped
// names sort lexically the same as by time).
func backupFiles(dir string) []string {
ents, err := os.ReadDir(dir)
if err != nil {
return nil
}
var names []string
for _, e := range ents {
if !e.IsDir() && strings.HasPrefix(e.Name(), prefix) && strings.HasSuffix(e.Name(), suffix) {
names = append(names, e.Name())
}
}
sort.Strings(names)
return names
}
func newestBackup(dir string) time.Time {
names := backupFiles(dir)
if len(names) == 0 {
return time.Time{}
}
last := strings.TrimSuffix(strings.TrimPrefix(names[len(names)-1], prefix), suffix)
t, err := time.Parse(stamp, last) // layout has no zone → parsed as UTC
if err != nil {
return time.Time{}
}
return t
}
// prune deletes the oldest backups beyond the most recent `keep`.
func prune(dir string, keep int) error {
names := backupFiles(dir)
if keep <= 0 || len(names) <= keep {
return nil
}
for _, name := range names[:len(names)-keep] {
if err := os.Remove(filepath.Join(dir, name)); err != nil {
return err
}
}
return nil
}

View file

@ -0,0 +1,100 @@
package backup
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// fakeSnap writes a tiny file so backup files exist on disk for the tests, and
// records how many times it ran.
type fakeSnap struct{ calls int }
func (f *fakeSnap) SnapshotTo(dest string, includeBlobs bool) error {
f.calls++
return os.WriteFile(dest, []byte("snap"), 0o600)
}
func TestWriteBackupAndNewest(t *testing.T) {
dir := t.TempDir()
s := &fakeSnap{}
now := time.Date(2026, 6, 10, 9, 0, 0, 0, time.UTC)
if err := writeBackup(s, dir, now); err != nil {
t.Fatal(err)
}
files := backupFiles(dir)
if len(files) != 1 || files[0] != "hsa-backup-2026-06-10-090000.db" {
t.Fatalf("files = %v", files)
}
if got := newestBackup(dir); !got.Equal(now) {
t.Errorf("newestBackup = %v, want %v", got, now)
}
}
func TestUntilNext(t *testing.T) {
dir := t.TempDir()
every := 168 * time.Hour
now := time.Date(2026, 6, 10, 9, 0, 0, 0, time.UTC)
// No backups yet → back up immediately.
if d := untilNext(dir, every, now); d != 0 {
t.Errorf("empty dir: untilNext = %v, want 0", d)
}
// A fresh backup → wait nearly the full interval.
_ = writeBackup(&fakeSnap{}, dir, now.Add(-1*time.Hour))
if d := untilNext(dir, every, now); d <= 0 || d > every {
t.Errorf("fresh backup: untilNext = %v, want ~167h", d)
}
// An old backup → back up now.
dir2 := t.TempDir()
_ = writeBackup(&fakeSnap{}, dir2, now.Add(-200*time.Hour))
if d := untilNext(dir2, every, now); d != 0 {
t.Errorf("stale backup: untilNext = %v, want 0", d)
}
}
func TestPruneKeepsMostRecent(t *testing.T) {
dir := t.TempDir()
s := &fakeSnap{}
base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
for i := 0; i < 5; i++ {
_ = writeBackup(s, dir, base.AddDate(0, 0, i))
}
if err := prune(dir, 2); err != nil {
t.Fatal(err)
}
files := backupFiles(dir)
if len(files) != 2 {
t.Fatalf("after prune: %v", files)
}
// The two newest (Jan 04, Jan 05) survive.
if !strings.Contains(files[0], "2026-01-04") || !strings.Contains(files[1], "2026-01-05") {
t.Errorf("wrong files kept: %v", files)
}
}
func TestScheduleRunsOnceThenStops(t *testing.T) {
dir := t.TempDir()
s := &fakeSnap{}
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
// A real store would be passed in production; here the fake is enough.
go Schedule(ctx, s, dir, time.Hour, 4, func() time.Time {
return time.Date(2026, 6, 10, 9, 0, 0, 0, time.UTC)
})
// Wait for the immediate first backup to land.
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if _, err := os.Stat(filepath.Join(dir, "hsa-backup-2026-06-10-090000.db")); err == nil {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("Schedule did not write an initial backup")
}

View file

@ -7,6 +7,7 @@ import (
"os"
"strconv"
"strings"
"time"
)
// DefaultClassifyModel is the model used for receipt classification unless
@ -32,6 +33,10 @@ type Config struct {
ConfigPath string // path to config.json (people + categories)
ClassifyAPIKey string // Anthropic API key; empty disables auto-classification
ClassifyModel string // model id for classification
BackupDir string // directory for scheduled metadata-only DB backups
BackupEvery time.Duration // backup interval; <= 0 disables scheduled backups
BackupKeep int // number of most-recent backups to retain
}
// Load reads configuration from the environment, validating required values.
@ -69,7 +74,9 @@ func Load() (Config, error) {
c.SecureCookies = strings.HasPrefix(c.RedirectURL, "https://")
c.DBPath = envOr("DB_PATH", "./data/hsa.db")
c.StorageDir = envOr("STORAGE_DIR", "./data/receipts")
// STORAGE_DIR is the storage ROOT; receipts and attachments are stored under
// <STORAGE_DIR>/receipts/<year>/ and <STORAGE_DIR>/attachments/<year>/.
c.StorageDir = envOr("STORAGE_DIR", "./data")
maxMB := 32
if v := strings.TrimSpace(os.Getenv("MAX_UPLOAD_MB")); v != "" {
@ -84,6 +91,22 @@ func Load() (Config, error) {
c.ClassifyAPIKey = strings.TrimSpace(os.Getenv("CLAUDE_API_KEY"))
c.ClassifyModel = envOr("CLASSIFY_MODEL", DefaultClassifyModel)
// Scheduled metadata-only DB backups (on by default, weekly). Set
// BACKUP_INTERVAL=0 to disable.
c.BackupDir = envOr("BACKUP_DIR", "./data/backups")
c.BackupEvery = 7 * 24 * time.Hour
if v := strings.TrimSpace(os.Getenv("BACKUP_INTERVAL")); v != "" {
if d, err := time.ParseDuration(v); err == nil {
c.BackupEvery = d
}
}
c.BackupKeep = 8
if v := strings.TrimSpace(os.Getenv("BACKUP_KEEP")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
c.BackupKeep = n
}
}
return c, nil
}

View file

@ -21,13 +21,16 @@ func (s *Store) SnapshotTo(destPath string, includeBlobs bool) error {
}
// Strip blobs from the copy only. image_data is NOT NULL, so use an empty blob.
// Both receipts and their attachments carry blobs.
snap, err := sql.Open("sqlite", destPath)
if err != nil {
return fmt.Errorf("open snapshot: %w", err)
}
defer snap.Close()
if _, err := snap.Exec(`UPDATE receipts SET image_data = X''`); err != nil {
return fmt.Errorf("strip blobs: %w", err)
for _, table := range []string{"receipts", "attachments"} {
if _, err := snap.Exec(`UPDATE ` + table + ` SET image_data = X''`); err != nil {
return fmt.Errorf("strip %s blobs: %w", table, err)
}
}
if _, err := snap.Exec(`VACUUM`); err != nil {
return fmt.Errorf("vacuum stripped snapshot: %w", err)

View file

@ -3,6 +3,8 @@ package storage
import (
"path/filepath"
"testing"
"maisym.com/hsa/internal/receipt"
)
func TestSnapshotWithBlobs(t *testing.T) {
@ -38,6 +40,15 @@ func TestSnapshotWithoutBlobs(t *testing.T) {
if err := s.Insert(r); err != nil {
t.Fatal(err)
}
// An attachment blob must be stripped too.
att := receipt.Attachment{
ID: "att1", ReceiptID: r.ID, UploadedBy: "jm", UploadedAt: r.UploadedAt,
FilePath: "attachments/2026/x.png", ImageData: []byte("blobbytes"),
FileSizeBytes: 9, OriginalFilename: "x.png", MimeType: "image/png",
}
if err := s.InsertAttachment(att); err != nil {
t.Fatal(err)
}
dest := filepath.Join(t.TempDir(), "snap-noblob.db")
if err := s.SnapshotTo(dest, false); err != nil {
@ -63,9 +74,20 @@ func TestSnapshotWithoutBlobs(t *testing.T) {
t.Fatal(err)
}
if len(got.ImageData) != 0 {
t.Errorf("blob not stripped: got %d bytes", len(got.ImageData))
t.Errorf("receipt blob not stripped: got %d bytes", len(got.ImageData))
}
if got.AmountCents != r.AmountCents {
t.Errorf("metadata lost: amount = %d, want %d", got.AmountCents, r.AmountCents)
}
// Attachment metadata survives, blob stripped.
gotAtt, err := snap.GetAttachment("att1")
if err != nil {
t.Fatal(err)
}
if len(gotAtt.ImageData) != 0 {
t.Errorf("attachment blob not stripped: got %d bytes", len(gotAtt.ImageData))
}
if gotAtt.OriginalFilename != "x.png" {
t.Errorf("attachment metadata lost: %+v", gotAtt)
}
}

25
spec.md
View file

@ -306,4 +306,27 @@ Lifecycle / interactions:
Out of scope (for now):
- Adding attachments to an already-saved receipt (would need an edit/detail page).
- No AI parsing of attachments; no per-attachment metadata beyond the file.
- No reordering UI beyond upload order.
- No reordering UI beyond upload order.
11. Scheduled metadata-only DB backups
The app writes a periodic, metadata-only snapshot of the database to a local
directory so the receipt metadata always has a recent recoverable copy, without an
external cron job.
- Built on the existing snapshot primitive (VACUUM INTO, then blob-strip): the
backup excludes image blobs from BOTH receipts and attachments, so it is tiny.
The on-disk files and the full-blob export (/export/db) remain the source for
the images themselves.
- On by default, weekly. Config: BACKUP_DIR (default ./data/backups),
BACKUP_INTERVAL (Go duration, default 168h; set 0 to disable), BACKUP_KEEP
(most-recent copies to retain, default 8).
- Files are named hsa-backup-<UTC timestamp>.db so they sort chronologically;
older copies beyond BACKUP_KEEP are pruned.
- Restart-safe: on startup it backs up immediately only if the newest existing
backup is older than the interval, so frequent restarts don't spam the dir and
a long gap is covered right away. Runs in a background goroutine for the life
of the process; failures are logged, never fatal.
Storage-root note: STORAGE_DIR is the storage ROOT (not the receipts subdir).
Receipts live under <STORAGE_DIR>/receipts/<year>/ and attachments under
<STORAGE_DIR>/attachments/<year>/ (default STORAGE_DIR=./data).