返回 DeepSeek-Reasonix
reconcile_publication_test.go
根目录 / internal / sessioncatalog / reconcile_publication_test.go
1 package sessioncatalog
2
3 import (
4 "context"
5 "fmt"
6 "os"
7 "path/filepath"
8 "strings"
9 "sync"
10 "testing"
11 "time"
12
13 "reasonix/internal/agent"
14 )
15
16 func TestReconcilePublishesOnlyCompletedDirectorySnapshot(t *testing.T) {
17 t.Parallel()
18 ctx := context.Background()
19 dir := t.TempDir()
20 const sessionCount = 65
21 for i := range sessionCount {
22 path := filepath.Join(dir, fmt.Sprintf("chat-%03d.jsonl", i))
23 if err := os.WriteFile(path, []byte(`{"role":"user","content":"hi"}`+"\n"), 0o600); err != nil {
24 t.Fatal(err)
25 }
26 if err := agent.SaveBranchMeta(path, agent.BranchMeta{
27 Scope: "project",
28 WorkspaceRoot: "/workspace",
29 TopicID: fmt.Sprintf("topic-%03d", i),
30 TopicTitle: fmt.Sprintf("Topic %03d", i),
31 SchemaVersion: agent.BranchMetaCountsVersion,
32 Turns: 1,
33 }); err != nil {
34 t.Fatal(err)
35 }
36 }
37
38 events := make(chan string, sessionCount+1)
39 catalog, err := Open(ctx, Options{
40 InMemory: true,
41 DisableRepair: true,
42 OnRevision: func(_ uint64, _ []string, reason string) {
43 events <- reason
44 },
45 })
46 if err != nil {
47 t.Fatal(err)
48 }
49 t.Cleanup(func() { _ = catalog.Close(context.Background()) })
50
51 if err := catalog.ReconcileDirectory(ctx, DirectoryTarget{
52 Path: dir,
53 Scope: "project",
54 WorkspaceRoot: "/workspace",
55 }); err != nil {
56 t.Fatal(err)
57 }
58 close(events)
59 published := make([]string, 0, len(events))
60 for reason := range events {
61 published = append(published, reason)
62 }
63 if len(published) != 1 || published[0] != "reconcile_complete" {
64 t.Fatalf("published reasons = %q, want only reconcile_complete", published)
65 }
66
67 page, err := catalog.ListTopics(ctx, TopicPageRequest{
68 Scope: "project",
69 WorkspaceRoot: "/workspace",
70 Limit: sessionCount,
71 })
72 if err != nil {
73 t.Fatal(err)
74 }
75 if len(page.Items) != sessionCount {
76 t.Fatalf("final page items = %d, want %d", len(page.Items), sessionCount)
77 }
78 }
79
80 func TestReconcileBatchBoundaryIsOneAtomicSnapshot(t *testing.T) {
81 ctx := context.Background()
82 dir := t.TempDir()
83 const sessionCount = 65
84 paths := make([]string, 0, sessionCount)
85 for i := range sessionCount {
86 path := filepath.Join(dir, fmt.Sprintf("chat-%03d.jsonl", i))
87 paths = append(paths, path)
88 if err := os.WriteFile(path, []byte(`{"role":"user","content":"hi"}`+"\n"), 0o600); err != nil {
89 t.Fatal(err)
90 }
91 if err := agent.SaveBranchMeta(path, agent.BranchMeta{
92 Scope: "global", TopicID: fmt.Sprintf("old-%03d", i), TopicTitle: fmt.Sprintf("Old %03d", i),
93 SchemaVersion: agent.BranchMetaCountsVersion, Turns: 1,
94 }); err != nil {
95 t.Fatal(err)
96 }
97 }
98 catalog, err := Open(ctx, Options{Path: filepath.Join(t.TempDir(), "catalog.sqlite"), DisableRepair: true})
99 if err != nil {
100 t.Fatal(err)
101 }
102 t.Cleanup(func() { _ = catalog.Close(context.Background()) })
103 target := DirectoryTarget{Path: dir, Scope: "global"}
104 if err := catalog.ReconcileDirectory(ctx, target); err != nil {
105 t.Fatal(err)
106 }
107 for i, path := range paths {
108 if err := agent.UpdateBranchMeta(path, false, func(meta *agent.BranchMeta) error {
109 meta.TopicID = fmt.Sprintf("new-%03d", i)
110 meta.TopicTitle = fmt.Sprintf("New %03d", i)
111 return nil
112 }); err != nil {
113 t.Fatal(err)
114 }
115 }
116 paused := make(chan struct{})
117 release := make(chan struct{})
118 var once sync.Once
119 catalog.testReconcileBatchHook = func(processed int) {
120 if processed == 64 {
121 once.Do(func() {
122 close(paused)
123 <-release
124 })
125 }
126 }
127 done := make(chan error, 1)
128 go func() { done <- catalog.ReconcileDirectory(ctx, target) }()
129 select {
130 case <-paused:
131 case <-time.After(5 * time.Second):
132 t.Fatal("reconcile did not reach the first batch boundary")
133 }
134 page, err := catalog.ListTopics(ctx, TopicPageRequest{Scope: "global", Limit: sessionCount})
135 if err != nil {
136 t.Fatal(err)
137 }
138 if len(page.Items) != sessionCount {
139 t.Fatalf("uncommitted page size = %d, want old snapshot %d", len(page.Items), sessionCount)
140 }
141 for _, item := range page.Items {
142 if !strings.HasPrefix(item.TopicID, "old-") {
143 t.Fatalf("reader observed partial new projection at batch boundary: %q", item.TopicID)
144 }
145 }
146 close(release)
147 if err := <-done; err != nil {
148 t.Fatal(err)
149 }
150 page, err = catalog.ListTopics(ctx, TopicPageRequest{Scope: "global", Limit: sessionCount})
151 if err != nil {
152 t.Fatal(err)
153 }
154 if len(page.Items) != sessionCount {
155 t.Fatalf("committed page size = %d, want %d", len(page.Items), sessionCount)
156 }
157 for _, item := range page.Items {
158 if !strings.HasPrefix(item.TopicID, "new-") {
159 t.Fatalf("reader retained old projection after commit: %q", item.TopicID)
160 }
161 }
162 }
163
164 func TestReconcileSQLFailureRollsBackSnapshotAndRevision(t *testing.T) {
165 ctx := context.Background()
166 dir := t.TempDir()
167 path := filepath.Join(dir, "chat.jsonl")
168 if err := os.WriteFile(path, []byte(`{"role":"user","content":"hi"}`+"\n"), 0o600); err != nil {
169 t.Fatal(err)
170 }
171 if err := agent.SaveBranchMeta(path, agent.BranchMeta{
172 Scope: "global", TopicID: "topic", TopicTitle: "Topic", Preview: "before",
173 SchemaVersion: agent.BranchMetaCountsVersion, Turns: 1,
174 }); err != nil {
175 t.Fatal(err)
176 }
177 catalog, err := Open(ctx, Options{InMemory: true, DisableRepair: true})
178 if err != nil {
179 t.Fatal(err)
180 }
181 t.Cleanup(func() { _ = catalog.Close(context.Background()) })
182 target := DirectoryTarget{Path: dir, Scope: "global"}
183 if err := catalog.ReconcileDirectory(ctx, target); err != nil {
184 t.Fatal(err)
185 }
186 revision := catalog.Status().Revision
187 if err := agent.UpdateBranchMeta(path, false, func(meta *agent.BranchMeta) error {
188 meta.Preview = "after"
189 return nil
190 }); err != nil {
191 t.Fatal(err)
192 }
193 if _, err := catalog.db.ExecContext(ctx, `CREATE TRIGGER fail_atomic_projection
194 BEFORE UPDATE OF preview ON catalog_sessions
195 BEGIN SELECT RAISE(FAIL, 'injected projection failure'); END`); err != nil {
196 t.Fatal(err)
197 }
198 if err := catalog.ReconcileDirectory(ctx, target); err == nil {
199 t.Fatal("ReconcileDirectory succeeded despite injected SQL failure")
200 }
201 got, ok, err := catalog.GetSession(ctx, path)
202 if err != nil || !ok {
203 t.Fatalf("GetSession: ok=%v err=%v", ok, err)
204 }
205 if got.Preview != "before" {
206 t.Fatalf("rolled-back preview = %q, want before", got.Preview)
207 }
208 if gotRevision := catalog.Status().Revision; gotRevision != revision {
209 t.Fatalf("revision after rollback = %d, want %d", gotRevision, revision)
210 }
211 }
212
213 func TestExactIndexAndReconcileConvergeUnderDirectoryLock(t *testing.T) {
214 ctx := context.Background()
215 dir := t.TempDir()
216 path := filepath.Join(dir, "chat.jsonl")
217 if err := os.WriteFile(path, []byte(`{"role":"user","content":"hi"}`+"\n"), 0o600); err != nil {
218 t.Fatal(err)
219 }
220 if err := agent.SaveBranchMeta(path, agent.BranchMeta{
221 Scope: "global", TopicID: "topic", TopicTitle: "Topic", Preview: "initial",
222 SchemaVersion: agent.BranchMetaCountsVersion, Turns: 1,
223 }); err != nil {
224 t.Fatal(err)
225 }
226 catalog, err := Open(ctx, Options{Path: filepath.Join(t.TempDir(), "catalog.sqlite"), DisableRepair: true})
227 if err != nil {
228 t.Fatal(err)
229 }
230 t.Cleanup(func() { _ = catalog.Close(context.Background()) })
231 target := DirectoryTarget{Path: dir, Scope: "global"}
232 if err := catalog.ReconcileDirectory(ctx, target); err != nil {
233 t.Fatal(err)
234 }
235 if err := agent.UpdateBranchMeta(path, false, func(meta *agent.BranchMeta) error {
236 meta.Preview = "reconcile snapshot"
237 return nil
238 }); err != nil {
239 t.Fatal(err)
240 }
241 paused := make(chan struct{})
242 release := make(chan struct{})
243 var once sync.Once
244 catalog.testReconcileBatchHook = func(processed int) {
245 if processed == 1 {
246 once.Do(func() {
247 close(paused)
248 <-release
249 })
250 }
251 }
252 reconcileDone := make(chan error, 1)
253 go func() { reconcileDone <- catalog.ReconcileDirectory(ctx, target) }()
254 <-paused
255 if err := agent.UpdateBranchMeta(path, false, func(meta *agent.BranchMeta) error {
256 meta.Preview = "exact snapshot"
257 return nil
258 }); err != nil {
259 t.Fatal(err)
260 }
261 exactStarted := make(chan struct{})
262 exactDone := make(chan error, 1)
263 go func() {
264 close(exactStarted)
265 exactDone <- catalog.IndexSessionPath(ctx, target, path)
266 }()
267 <-exactStarted
268 select {
269 case err := <-exactDone:
270 t.Fatalf("exact index escaped directory lock before reconcile commit: %v", err)
271 default:
272 }
273 close(release)
274 if err := <-reconcileDone; err != nil {
275 t.Fatal(err)
276 }
277 if err := <-exactDone; err != nil {
278 t.Fatal(err)
279 }
280 if err := catalog.ReconcileDirectory(ctx, target); err != nil {
281 t.Fatal(err)
282 }
283 record, ok, err := catalog.GetSession(ctx, path)
284 if err != nil || !ok {
285 t.Fatalf("GetSession: ok=%v err=%v", ok, err)
286 }
287 if record.Preview != "exact snapshot" || !record.OrdinaryVisible || record.TopicID != "topic" {
288 t.Fatalf("final exact/reconcile projection = %+v", record)
289 }
290 page, err := catalog.ListTopics(ctx, TopicPageRequest{Scope: "global", Limit: 50})
291 if err != nil || len(page.Items) != 1 {
292 t.Fatalf("ListTopics after interleave: items=%+v err=%v", page.Items, err)
293 }
294 }
295
295 lines GO