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}