package storage import ( "context" "errors" "fmt" "os" "path/filepath" "slices" "strings" "time" ) const ( snapshotPrefix = "tug-" snapshotSuffix = ".sqlite3" snapshotPartial = ".partial" snapshotStamp = "20060102T150405Z" ) func (s *Store) Snapshot(ctx context.Context, dir string, at time.Time) (string, error) { const operation = "snapshot" if err := s.checkOpen(operation); err != nil { return "", err } if err := os.MkdirAll(dir, 0o700); err != nil { return "", classify(operation+" directory", err) } if err := os.Chmod(dir, 0o700); err != nil { return "", classify(operation+" directory", err) } final := filepath.Join(dir, snapshotPrefix+at.UTC().Format(snapshotStamp)+snapshotSuffix) partial := final + snapshotPartial file, err := os.OpenFile(partial, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) if err != nil { return "", classify(operation+" create", err) } if err := file.Close(); err != nil { os.Remove(partial) return "", classify(operation+" create", err) } if _, err := s.sql.ExecContext(ctx, `VACUUM INTO ?`, partial); err != nil { os.Remove(partial) return "", classify(operation, err) } if err := syncPath(partial); err != nil { os.Remove(partial) return "", classify(operation+" sync", err) } if err := os.Rename(partial, final); err != nil { os.Remove(partial) return "", classify(operation+" rename", err) } return final, classify(operation+" sync directory", syncPath(dir)) } func PruneSnapshots(dir string, keep int) ([]string, error) { if keep < 1 { return nil, fmt.Errorf("prune snapshots: keep %d is below one", keep) } entries, err := os.ReadDir(dir) if err != nil { return nil, fmt.Errorf("prune snapshots: %w", err) } var kept, doomed []string for _, e := range entries { name := e.Name() if !e.Type().IsRegular() || !strings.HasPrefix(name, snapshotPrefix) { continue } stamp, partial := strings.CutSuffix(name, snapshotSuffix+snapshotPartial) if !partial { stamp, _ = strings.CutSuffix(name, snapshotSuffix) } if _, err := time.Parse(snapshotStamp, strings.TrimPrefix(stamp, snapshotPrefix)); err != nil || stamp == name { continue } if partial { doomed = append(doomed, name) continue } kept = append(kept, name) } slices.Sort(kept) if len(kept) > keep { doomed = append(doomed, kept[:len(kept)-keep]...) } var errs []error var removed []string for _, name := range doomed { if err := os.Remove(filepath.Join(dir, name)); err != nil { errs = append(errs, err) continue } removed = append(removed, name) } return removed, errors.Join(errs...) } func syncPath(path string) error { f, err := os.Open(path) if err != nil { return err } if err := f.Sync(); err != nil { f.Close() return err } return f.Close() } func NewestSnapshot(dir string) (time.Time, bool) { entries, err := os.ReadDir(dir) if err != nil { return time.Time{}, false } var newest time.Time for _, e := range entries { stamp, ok := strings.CutSuffix(e.Name(), snapshotSuffix) if !ok || !e.Type().IsRegular() { continue } at, err := time.Parse(snapshotStamp, strings.TrimPrefix(stamp, snapshotPrefix)) if err == nil && strings.HasPrefix(stamp, snapshotPrefix) && at.After(newest) { newest = at } } return newest, !newest.IsZero() }