返回 DeepSeek-Reasonix
save_dag_shutdown_test.go
根目录 / internal / agent / save_dag_shutdown_test.go
1 package agent
2
3 import (
4 "errors"
5 "os"
6 "strings"
7 "testing"
8 "time"
9
10 "reasonix/internal/fileutil"
11 "reasonix/internal/provider"
12 "reasonix/internal/store"
13 )
14
15 // holdLockAndShortenWait makes the next save time out on the file lock the
16 // way a stalled peer process would cause it to.
17 func holdLockAndShortenWait(t *testing.T, path string) (release func()) {
18 t.Helper()
19 restore := SetSessionFileLockWaitForTest(40*time.Millisecond, 5*time.Millisecond)
20 t.Cleanup(restore)
21 held, err := HoldSessionFileLockForTest(path)
22 if err != nil {
23 t.Fatalf("hold session lock: %v", err)
24 }
25 released := false
26 release = func() {
27 if !released {
28 released = true
29 held()
30 }
31 }
32 t.Cleanup(release)
33 return release
34 }
35
36 func headsByKind(t *testing.T, path string) (main, other SessionHead) {
37 t.Helper()
38 heads, err := ListSessionHeads(path)
39 if err != nil || len(heads) != 2 {
40 t.Fatalf("heads = %+v err=%v, want main plus one more", heads, err)
41 }
42 return heads[0], heads[1]
43 }
44
45 func TestAppendForShutdownWithoutLockKeepsTailOnFreshHead(t *testing.T) {
46 path := dagTestSession(t)
47 s := dagSavedSession(t, path, "q1", "a1")
48 s.Add(provider.Message{Role: provider.RoleUser, Content: "q2"})
49 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "a2"})
50 release := holdLockAndShortenWait(t, path)
51 if err := s.SaveSnapshot(path); !errors.Is(err, ErrSessionFileLockHeld) {
52 t.Fatalf("locked save err = %v, want ErrSessionFileLockHeld", err)
53 }
54 handled, err := s.AppendForShutdownWithoutLock(path, false)
55 if err != nil || !handled {
56 t.Fatalf("AppendForShutdownWithoutLock = handled %v err %v", handled, err)
57 }
58 assertNoTranscriptCopies(t, path)
59 main, tail := headsByKind(t, path)
60 if tail.Kind != HeadKindConcurrent || !tail.Selected || tail.MessageCount != 5 || tail.ParentHead != SessionMainHead {
61 t.Fatalf("shutdown head = %+v", tail)
62 }
63 if !main.Covered || main.MessageCount != 3 {
64 t.Fatalf("main head after unlocked append = %+v, want it covered by the shutdown head", main)
65 }
66 if ref, ok := s.Head(); !ok || ref.HeadID != tail.ID {
67 t.Fatalf("session head = %+v ok=%v, want the shutdown head", ref, ok)
68 }
69 events := s.DrainHeadEvents()
70 if len(events) != 1 || events[0].Kind != HeadEventForkedConcurrent || events[0].HeadID != tail.ID {
71 t.Fatalf("head events = %+v", events)
72 }
73 release()
74 // The next locked save continues on the shutdown head and finally refreshes
75 // the derived files it skipped.
76 if err := s.Save(path); err != nil {
77 t.Fatalf("locked save after shutdown append: %v", err)
78 }
79 if meta, _, _ := LoadBranchMeta(path); meta.HeadID != tail.ID || meta.HeadCount != 2 {
80 t.Fatalf("meta mirror after the next locked save = head %q count %d", meta.HeadID, meta.HeadCount)
81 }
82 s.Add(provider.Message{Role: provider.RoleUser, Content: "q3"})
83 if err := s.Save(path); err != nil {
84 t.Fatal(err)
85 }
86 st := dagReplay(t, path)
87 if got := strings.Join(dagChain(st, tail.ID), ","); got != "sys,q1,a1,q2,a2,q3" {
88 t.Fatalf("shutdown head chain = %s", got)
89 }
90 if len(st.heads) != 2 {
91 t.Fatalf("a later locked save must not fork again: %d heads", len(st.heads))
92 }
93 reloaded, err := LoadSession(path)
94 if err != nil {
95 t.Fatal(err)
96 }
97 if ref, _ := reloaded.Head(); ref.HeadID != tail.ID || len(reloaded.Messages) != 6 {
98 t.Fatalf("reload = head %q with %d messages", ref.HeadID, len(reloaded.Messages))
99 }
100 }
101
102 func TestAppendForShutdownWithoutLockAfterTornTail(t *testing.T) {
103 path := dagTestSession(t)
104 s := dagSavedSession(t, path, "q1", "a1")
105 s.Add(provider.Message{Role: provider.RoleUser, Content: "q2"})
106 logPath := store.SessionEventLog(path)
107 f, err := os.OpenFile(logPath, os.O_WRONLY|os.O_APPEND, 0o600)
108 if err != nil {
109 t.Fatal(err)
110 }
111 if _, err := f.WriteString(`{"schema_version":2,"type":"message","id":"torn-by-a-crash","head":"main","msgs":[{"role":"user","con`); err != nil {
112 t.Fatal(err)
113 }
114 _ = f.Close()
115 release := holdLockAndShortenWait(t, path)
116 if err := s.SaveSnapshot(path); !errors.Is(err, ErrSessionFileLockHeld) {
117 t.Fatalf("locked save err = %v", err)
118 }
119 if handled, err := s.AppendForShutdownWithoutLock(path, false); err != nil || !handled {
120 t.Fatalf("AppendForShutdownWithoutLock = handled %v err %v", handled, err)
121 }
122 st := dagReplay(t, path)
123 if st.damaged || st.holes != 1 {
124 t.Fatalf("replay after append behind a torn tail: damaged=%v holes=%d", st.damaged, st.holes)
125 }
126 _, tail := headsByKind(t, path)
127 if got := strings.Join(dagChain(st, tail.ID), ","); got != "sys,q1,a1,q2" {
128 t.Fatalf("shutdown head chain = %s", got)
129 }
130 raw, _ := os.ReadFile(logPath)
131 if !strings.Contains(string(raw), "torn-by-a-crash") {
132 t.Fatal("the torn line must stay in the log until a rotation reclaims it")
133 }
134 release()
135 reloaded, err := LoadSession(path)
136 if err != nil || len(reloaded.Messages) != 4 || reloaded.Messages[3].Content != "q2" {
137 t.Fatalf("reload = %d messages err=%v", len(reloaded.Messages), err)
138 }
139 // With the lease held (the single-writer proof), the next locked save
140 // rotates the hole away even though it has nothing new to append.
141 lease, err := TryAcquireSessionLease(path)
142 if err != nil {
143 t.Fatalf("TryAcquireSessionLease: %v", err)
144 }
145 defer lease.Release()
146 if err := reloaded.Save(path); err != nil {
147 t.Fatalf("locked save after a hole: %v", err)
148 }
149 if again := dagReplay(t, path); again.holes != 0 || again.generation != 2 {
150 t.Fatalf("the next locked save must rotate the hole away: holes=%d generation=%d", again.holes, again.generation)
151 }
152 }
153
154 func TestAppendForShutdownWithoutLockWritesMarkersOnTheSameHead(t *testing.T) {
155 path := dagTestSession(t)
156 s := dagSavedSession(t, path, "q1")
157 if !s.QueueTurnBegin("turn-1", false) {
158 t.Fatal("QueueTurnBegin refused")
159 }
160 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "a1"})
161 if err := s.Save(path); err != nil {
162 t.Fatal(err)
163 }
164 if !s.QueueTurnEnd("turn-1") {
165 t.Fatal("QueueTurnEnd refused")
166 }
167 release := holdLockAndShortenWait(t, path)
168 if err := s.SaveSnapshot(path); !errors.Is(err, ErrSessionFileLockHeld) {
169 t.Fatalf("locked save err = %v", err)
170 }
171 if handled, err := s.AppendForShutdownWithoutLock(path, false); err != nil || !handled {
172 t.Fatalf("AppendForShutdownWithoutLock = handled %v err %v", handled, err)
173 }
174 release()
175 st := dagReplay(t, path)
176 if len(st.heads) != 1 || st.heads[SessionMainHead].openTurn != nil {
177 t.Fatalf("a marker-only batch must close the turn on the same head: heads=%d openTurn=%+v", len(st.heads), st.heads[SessionMainHead].openTurn)
178 }
179 if got := dagEntryTypes(t, path); got[len(got)-1] != sessionDAGTypeTurnEnd {
180 t.Fatalf("entries = %v, want the turn_end appended last", got)
181 }
182 }
183
184 func TestAppendForShutdownWithoutLockLeavesSchemaOneToRecovery(t *testing.T) {
185 useSchemaOneLog(t)
186 path := dagTestSession(t)
187 s := dagSavedSession(t, path, "q1")
188 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "a1"})
189 holdLockAndShortenWait(t, path)
190 if handled, err := s.AppendForShutdownWithoutLock(path, false); handled || err != nil {
191 t.Fatalf("schema-1 session = handled %v err %v, want the recovery-copy path", handled, err)
192 }
193 }
194
195 func TestReplaySkipsTornLineBetweenEntries(t *testing.T) {
196 path := dagTestSession(t)
197 ids, base := dagLinearLog(t, path)
198 logPath := store.SessionEventLog(path)
199 f, err := os.OpenFile(logPath, os.O_WRONLY|os.O_APPEND, 0o600)
200 if err != nil {
201 t.Fatal(err)
202 }
203 if _, err := f.WriteString(`{"schema_version":2,"type":"message","id":"torn","head":"ma`); err != nil {
204 t.Fatal(err)
205 }
206 _ = f.Close()
207 torn := dagReplay(t, path)
208 if !torn.damaged || torn.holes != 0 || len(torn.nodes) != len(ids) {
209 t.Fatalf("torn tail must stay damaged: damaged=%v holes=%d nodes=%d", torn.damaged, torn.holes, len(torn.nodes))
210 }
211 f, err = os.OpenFile(logPath, os.O_WRONLY|os.O_APPEND, 0o600)
212 if err != nil {
213 t.Fatal(err)
214 }
215 if _, err := f.WriteString("\n"); err != nil {
216 t.Fatal(err)
217 }
218 _ = f.Close()
219 dagAppend(t, path, dagMessageEntry(t, SessionMainHead, ids[len(ids)-1], "", dagMsg(provider.RoleAssistant, "after the hole", "after"), base.Add(time.Minute)))
220 st := dagReplay(t, path)
221 if st.damaged || st.holes != 1 || len(st.nodes) != len(ids)+1 {
222 t.Fatalf("replay past a hole: damaged=%v holes=%d nodes=%d", st.damaged, st.holes, len(st.nodes))
223 }
224 if got := dagChain(st, SessionMainHead); got[len(got)-1] != "after the hole" {
225 t.Fatalf("chain = %v", got)
226 }
227 if repaired, err := repairSessionDAGTail(path, st, time.Now().Add(time.Hour)); repaired || err != nil {
228 t.Fatalf("a hole is not a torn tail: repaired=%v err=%v", repaired, err)
229 }
230 }
231
232 func TestRotationCarriesAppendsThatLandedDuringReplace(t *testing.T) {
233 path := dagTestSession(t)
234 s := dagSavedSession(t, path, "q1", "a1")
235 st := dagReplay(t, path)
236 leaf := st.heads[SessionMainHead].leaf
237 sessionDAGRotateBeforeReplace = func(sessionPath string) {
238 dagAppend(t, sessionPath, dagMessageEntry(t, SessionMainHead, leaf, "", dagMsg(provider.RoleUser, "landed mid-rotation", "late"), time.Now().UTC()))
239 }
240 t.Cleanup(func() { sessionDAGRotateBeforeReplace = nil })
241 if err := rotateSessionDAG(path, st, time.Now().UTC()); err != nil {
242 t.Fatalf("rotate: %v", err)
243 }
244 sessionDAGRotateBeforeReplace = nil
245 fresh := dagReplay(t, path)
246 if fresh.generation != 2 {
247 t.Fatalf("generation after rotation = %d", fresh.generation)
248 }
249 if got := strings.Join(dagChain(fresh, SessionMainHead), ","); got != "sys,q1,a1,landed mid-rotation" {
250 t.Fatalf("chain after rotation = %s, want the late append carried over", got)
251 }
252 _ = s
253 }
254
255 func TestUnlockedAppendWaitsForRotationMarkerThenReappends(t *testing.T) {
256 path := dagTestSession(t)
257 dagSavedSession(t, path, "q1", "a1")
258 st := dagReplay(t, path)
259 leaf := st.heads[SessionMainHead].leaf
260 rotated, err := buildRotatedSessionDAG(st, time.Now().UTC())
261 if err != nil {
262 t.Fatal(err)
263 }
264 data, err := encodeSessionDAGEntries(rotated, time.Now().UTC())
265 if err != nil {
266 t.Fatal(err)
267 }
268 marker := store.SessionEventLogRotating(path)
269 if err := os.WriteFile(marker, nil, 0o600); err != nil {
270 t.Fatal(err)
271 }
272 // A rotation that already read the log publishes its replacement while the
273 // appender is waiting on the marker, then clears the marker.
274 published := make(chan struct{})
275 go func() {
276 defer close(published)
277 time.Sleep(150 * time.Millisecond)
278 if err := fileutil.AtomicWriteFileStrict(store.SessionEventLog(path), data, 0o600); err != nil {
279 t.Error(err)
280 }
281 _ = os.Remove(marker)
282 }()
283 entry := dagMessageEntry(t, SessionMainHead, leaf, "", dagMsg(provider.RoleUser, "appended around the rotation", "around"), time.Now().UTC())
284 started := time.Now()
285 if _, err := appendSessionDAGEntriesUnlocked(path, []sessionDAGEntry{entry}); err != nil {
286 t.Fatalf("unlocked append: %v", err)
287 }
288 <-published
289 if time.Since(started) < 150*time.Millisecond {
290 t.Fatal("the append returned before the rotation marker cleared")
291 }
292 fresh := dagReplay(t, path)
293 if fresh.generation != 2 {
294 t.Fatalf("generation = %d, want the rotated log", fresh.generation)
295 }
296 if got := strings.Join(dagChain(fresh, SessionMainHead), ","); got != "sys,q1,a1,appended around the rotation" {
297 t.Fatalf("chain after the rotation = %s, want the append re-landed in the new log", got)
298 }
299 }
300
300 lines GO