返回 DeepSeek-Reasonix
layout_v3.go
根目录 / internal / checkpoint / layout_v3.go
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
264 lines GO