| 1 | package checkpoint |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "fmt" |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "strconv" |
| 9 | |
| 10 | "reasonix/internal/fileutil" |
| 11 | fileenc "reasonix/internal/fileutil/encoding" |
| 12 | ) |
| 13 | |
| 14 | func (s *Store) turnsDir() string { |
| 15 | return filepath.Join(s.dir, "turns") |
| 16 | } |
| 17 | |
| 18 | func (s *Store) turnDir(turn int) string { |
| 19 | return filepath.Join(s.turnsDir(), strconv.Itoa(turn)) |
| 20 | } |
| 21 | |
| 22 | func (s *Store) v3MetaPath(turn int) string { |
| 23 | return filepath.Join(s.turnDir(turn), "meta.json") |
| 24 | } |
| 25 | |
| 26 | func (s *Store) v3BeforePath(turn, index int) string { |
| 27 | return filepath.Join(s.turnDir(turn), "files", fmt.Sprintf("%04d.before", index)) |
| 28 | } |
| 29 | |
| 30 | func v3PayloadBytes(f FileSnap) []byte { |
| 31 | if f.rawContent != nil { |
| 32 | return f.rawContent |
| 33 | } |
| 34 | if f.Content == nil { |
| 35 | return nil |
| 36 | } |
| 37 | if f.Encoding != nil { |
| 38 | return fileenc.Encode(*f.Content, *f.Encoding) |
| 39 | } |
| 40 | return []byte(*f.Content) |
| 41 | } |
| 42 | |
| 43 | func (s *Store) persistV3(c *Checkpoint) error { |
| 44 | // Previous builds only inspect turn-N.json. Keep a payload-free v2 marker |
| 45 | // so their NextTurn remains monotonic across a downgrade; write it first so |
| 46 | // a crash can leave reduced rewind visibility, never an invisible turn. |
| 47 | marker := *c |
| 48 | marker.Result = nil |
| 49 | marker.SchemaVersion = SchemaV2 |
| 50 | marker.Files = []FileSnap{} |
| 51 | marker.Coverage = CoverageNone |
| 52 | marker.CoverageGaps = nil |
| 53 | marker.ActiveWriters = nil |
| 54 | marker.ExpiredFilePayload = true |
| 55 | markerBytes, err := json.Marshal(&marker) |
| 56 | if err != nil { |
| 57 | return err |
| 58 | } |
| 59 | if err := os.MkdirAll(s.dir, 0o755); err != nil { |
| 60 | return err |
| 61 | } |
| 62 | markerPath := filepath.Join(s.dir, fmt.Sprintf("turn-%d.json", c.Turn)) |
| 63 | if err := fileutil.AtomicWriteFileStrict(markerPath, markerBytes, 0o644); err != nil { |
| 64 | return err |
| 65 | } |
| 66 | |
| 67 | turnDir := s.turnDir(c.Turn) |
| 68 | if err := os.MkdirAll(filepath.Join(turnDir, "files"), 0o755); err != nil { |
| 69 | return err |
| 70 | } |
| 71 | wire := *c |
| 72 | wire.SchemaVersion = SchemaV3 |
| 73 | wire.Files = make([]FileSnap, len(c.Files)) |
| 74 | for i, f := range c.Files { |
| 75 | snap := f |
| 76 | snap.BlobRef = "" |
| 77 | payloadPath := s.v3BeforePath(c.Turn, i) |
| 78 | if f.Content != nil && !f.PayloadExpired { |
| 79 | if err := fileutil.AtomicWriteFile(payloadPath, v3PayloadBytes(f), 0o644); err != nil { |
| 80 | return err |
| 81 | } |
| 82 | snap.Content = nil |
| 83 | snap.Encoding = nil |
| 84 | } else if err := os.Remove(payloadPath); err != nil && !os.IsNotExist(err) { |
| 85 | return err |
| 86 | } |
| 87 | wire.Files[i] = snap |
| 88 | } |
| 89 | b, err := json.Marshal(&wire) |
| 90 | if err != nil { |
| 91 | return err |
| 92 | } |
| 93 | return fileutil.AtomicWriteFileStrict(s.v3MetaPath(c.Turn), b, 0o644) |
| 94 | } |
| 95 | |
| 96 | func (s *Store) removeTurnArtifacts(turns map[int]bool) error { |
| 97 | if s.dir == "" { |
| 98 | return nil |
| 99 | } |
| 100 | for turn := range turns { |
| 101 | // The compatibility marker is the cross-version liveness record. Remove |
| 102 | // payloads first so a crash can leave a visible payload-free turn, never |
| 103 | // a markerless directory that a newer reader resurrects after downgrade. |
| 104 | if err := os.RemoveAll(s.turnDir(turn)); err != nil { |
| 105 | return fmt.Errorf("remove checkpoint turn %d: %w", turn, err) |
| 106 | } |
| 107 | for _, path := range []string{ |
| 108 | filepath.Join(s.dir, fmt.Sprintf("turn-%d.json", turn)), |
| 109 | filepath.Join(s.expiredDir(), fmt.Sprintf("turn-%d.json", turn)), |
| 110 | } { |
| 111 | if err := os.Remove(path); err != nil && !os.IsNotExist(err) { |
| 112 | return fmt.Errorf("remove checkpoint turn %d: %w", turn, err) |
| 113 | } |
| 114 | } |
| 115 | } |
| 116 | return nil |
| 117 | } |
| 118 | |
| 119 | func (s *Store) loadV3Turns() []*Checkpoint { |
| 120 | ents, err := os.ReadDir(s.turnsDir()) |
| 121 | if err != nil { |
| 122 | return nil |
| 123 | } |
| 124 | var turns []*Checkpoint |
| 125 | for _, e := range ents { |
| 126 | if !e.IsDir() { |
| 127 | continue |
| 128 | } |
| 129 | turn, err := strconv.Atoi(e.Name()) |
| 130 | if err != nil { |
| 131 | continue |
| 132 | } |
| 133 | // Old readers truncate only turn-N.json. Treat marker absence as a |
| 134 | // tombstone so reopening with a newer build cannot revive those turns. |
| 135 | if _, err := os.Stat(filepath.Join(s.dir, fmt.Sprintf("turn-%d.json", turn))); err != nil { |
| 136 | continue |
| 137 | } |
| 138 | b, err := fileenc.ReadFileUTF8(s.v3MetaPath(turn)) |
| 139 | if err != nil { |
| 140 | continue |
| 141 | } |
| 142 | var c Checkpoint |
| 143 | if json.Unmarshal(b, &c) != nil { |
| 144 | continue |
| 145 | } |
| 146 | c.Turn = turn |
| 147 | c.SchemaVersion = SchemaV3 |
| 148 | for i := range c.Files { |
| 149 | if c.Files[i].PayloadExpired { |
| 150 | continue |
| 151 | } |
| 152 | raw, err := os.ReadFile(s.v3BeforePath(turn, i)) |
| 153 | if err != nil { |
| 154 | continue |
| 155 | } |
| 156 | if want := c.Files[i].SHA256; want != "" && Digest(raw) != want { |
| 157 | continue |
| 158 | } |
| 159 | enc, detected := fileenc.Detect(raw) |
| 160 | text := string(fileenc.Decode(detected, enc)) |
| 161 | c.Files[i].Content = &text |
| 162 | c.Files[i].Encoding = &enc |
| 163 | c.Files[i].BlobRef = "" |
| 164 | c.Files[i].rawContent = append([]byte(nil), raw...) |
| 165 | } |
| 166 | turns = append(turns, &c) |
| 167 | } |
| 168 | return turns |
| 169 | } |
| 170 | |
| 171 | func (s *Store) v3PayloadSize(turn int) (int64, error) { |
| 172 | // Metadata also holds the bounded frozen result patches. |
| 173 | root := s.turnDir(turn) |
| 174 | var total int64 |
| 175 | err := filepath.WalkDir(root, func(_ string, d os.DirEntry, err error) error { |
| 176 | if err != nil { |
| 177 | if os.IsNotExist(err) { |
| 178 | return nil |
| 179 | } |
| 180 | return err |
| 181 | } |
| 182 | if d.IsDir() { |
| 183 | return nil |
| 184 | } |
| 185 | info, err := d.Info() |
| 186 | if err != nil { |
| 187 | return err |
| 188 | } |
| 189 | if info.Mode().IsRegular() { |
| 190 | total += info.Size() |
| 191 | } |
| 192 | return nil |
| 193 | }) |
| 194 | if os.IsNotExist(err) { |
| 195 | return 0, nil |
| 196 | } |
| 197 | return total, err |
| 198 | } |
| 199 | |
| 200 | // pruneV3TurnsLocked applies both count retention and the legacy 1 GiB soft |
| 201 | // payload budget to complete v3 turn directories. The current and protected |
| 202 | // turns may temporarily exceed the budget; the next unprotected turn prunes |
| 203 | // whole oldest directories. Older v1/v2 metadata remains on its legacy path. |
| 204 | func (s *Store) pruneV3TurnsLocked() { |
| 205 | if s.dir == "" || s.retainN <= 0 { |
| 206 | return |
| 207 | } |
| 208 | var turns []*Checkpoint |
| 209 | for _, c := range s.all() { |
| 210 | if c.SchemaVersion >= SchemaV3 { |
| 211 | turns = append(turns, c) |
| 212 | } |
| 213 | } |
| 214 | excess := len(turns) - s.retainN |
| 215 | sizes := make(map[*Checkpoint]int64, len(turns)) |
| 216 | var totalSize int64 |
| 217 | sizeKnown := true |
| 218 | for _, c := range turns { |
| 219 | size, err := s.v3PayloadSize(c.Turn) |
| 220 | if err != nil { |
| 221 | sizeKnown = false |
| 222 | break |
| 223 | } |
| 224 | sizes[c] = size |
| 225 | totalSize += size |
| 226 | } |
| 227 | quotaExceeded := func() bool { |
| 228 | return sizeKnown && s.blobQuota > 0 && totalSize > s.blobQuota |
| 229 | } |
| 230 | if excess <= 0 && !quotaExceeded() { |
| 231 | return |
| 232 | } |
| 233 | removed := make(map[*Checkpoint]bool) |
| 234 | for _, c := range turns { |
| 235 | if excess <= 0 && !quotaExceeded() { |
| 236 | break |
| 237 | } |
| 238 | if c == s.cur || s.protectTurns[c.Turn] { |
| 239 | continue |
| 240 | } |
| 241 | if err := s.removeTurnArtifacts(map[int]bool{c.Turn: true}); err != nil { |
| 242 | continue |
| 243 | } |
| 244 | removed[c] = true |
| 245 | if excess > 0 { |
| 246 | excess-- |
| 247 | } |
| 248 | totalSize -= sizes[c] |
| 249 | } |
| 250 | if len(removed) == 0 { |
| 251 | return |
| 252 | } |
| 253 | kept := s.done[:0] |
| 254 | for _, c := range s.done { |
| 255 | if !removed[c] { |
| 256 | kept = append(kept, c) |
| 257 | } |
| 258 | } |
| 259 | s.done = kept |
| 260 | // Pre-v3 builds briefly wrote both a turn directory and a blob. Once such a |
| 261 | // turn ages out, the legacy mark-and-sweep can reclaim its orphaned blob. |
| 262 | s.pruneBlobsLocked() |
| 263 | } |
| 264 |