Raw
  1package storage
  2
  3import (
  4	"context"
  5	"errors"
  6	"fmt"
  7	"os"
  8	"path/filepath"
  9	"slices"
 10	"strings"
 11	"time"
 12)
 13
 14const (
 15	snapshotPrefix  = "tug-"
 16	snapshotSuffix  = ".sqlite3"
 17	snapshotPartial = ".partial"
 18	snapshotStamp   = "20060102T150405Z"
 19)
 20
 21func (s *Store) Snapshot(ctx context.Context, dir string, at time.Time) (string, error) {
 22	const operation = "snapshot"
 23	if err := s.checkOpen(operation); err != nil {
 24		return "", err
 25	}
 26	if err := os.MkdirAll(dir, 0o700); err != nil {
 27		return "", classify(operation+" directory", err)
 28	}
 29	if err := os.Chmod(dir, 0o700); err != nil {
 30		return "", classify(operation+" directory", err)
 31	}
 32	final := filepath.Join(dir, snapshotPrefix+at.UTC().Format(snapshotStamp)+snapshotSuffix)
 33	partial := final + snapshotPartial
 34	file, err := os.OpenFile(partial, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
 35	if err != nil {
 36		return "", classify(operation+" create", err)
 37	}
 38	if err := file.Close(); err != nil {
 39		os.Remove(partial)
 40		return "", classify(operation+" create", err)
 41	}
 42	if _, err := s.sql.ExecContext(ctx, `VACUUM INTO ?`, partial); err != nil {
 43		os.Remove(partial)
 44		return "", classify(operation, err)
 45	}
 46	if err := syncPath(partial); err != nil {
 47		os.Remove(partial)
 48		return "", classify(operation+" sync", err)
 49	}
 50	if err := os.Rename(partial, final); err != nil {
 51		os.Remove(partial)
 52		return "", classify(operation+" rename", err)
 53	}
 54	return final, classify(operation+" sync directory", syncPath(dir))
 55}
 56
 57func PruneSnapshots(dir string, keep int) ([]string, error) {
 58	if keep < 1 {
 59		return nil, fmt.Errorf("prune snapshots: keep %d is below one", keep)
 60	}
 61	entries, err := os.ReadDir(dir)
 62	if err != nil {
 63		return nil, fmt.Errorf("prune snapshots: %w", err)
 64	}
 65	var kept, doomed []string
 66	for _, e := range entries {
 67		name := e.Name()
 68		if !e.Type().IsRegular() || !strings.HasPrefix(name, snapshotPrefix) {
 69			continue
 70		}
 71		stamp, partial := strings.CutSuffix(name, snapshotSuffix+snapshotPartial)
 72		if !partial {
 73			stamp, _ = strings.CutSuffix(name, snapshotSuffix)
 74		}
 75		if _, err := time.Parse(snapshotStamp, strings.TrimPrefix(stamp, snapshotPrefix)); err != nil || stamp == name {
 76			continue
 77		}
 78		if partial {
 79			doomed = append(doomed, name)
 80			continue
 81		}
 82		kept = append(kept, name)
 83	}
 84	slices.Sort(kept)
 85	if len(kept) > keep {
 86		doomed = append(doomed, kept[:len(kept)-keep]...)
 87	}
 88	var errs []error
 89	var removed []string
 90	for _, name := range doomed {
 91		if err := os.Remove(filepath.Join(dir, name)); err != nil {
 92			errs = append(errs, err)
 93			continue
 94		}
 95		removed = append(removed, name)
 96	}
 97	return removed, errors.Join(errs...)
 98}
 99
100func syncPath(path string) error {
101	f, err := os.Open(path)
102	if err != nil {
103		return err
104	}
105	if err := f.Sync(); err != nil {
106		f.Close()
107		return err
108	}
109	return f.Close()
110}
111
112func NewestSnapshot(dir string) (time.Time, bool) {
113	entries, err := os.ReadDir(dir)
114	if err != nil {
115		return time.Time{}, false
116	}
117	var newest time.Time
118	for _, e := range entries {
119		stamp, ok := strings.CutSuffix(e.Name(), snapshotSuffix)
120		if !ok || !e.Type().IsRegular() {
121			continue
122		}
123		at, err := time.Parse(snapshotStamp, strings.TrimPrefix(stamp, snapshotPrefix))
124		if err == nil && strings.HasPrefix(stamp, snapshotPrefix) && at.After(newest) {
125			newest = at
126		}
127	}
128	return newest, !newest.IsZero()
129}