返回 DeepSeek-Reasonix
session_topic_lazy_snapshot_test.go
根目录 / desktop / session_topic_lazy_snapshot_test.go
1 package main
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "path/filepath"
8 "reflect"
9 "strings"
10 "testing"
11 "time"
12
13 "reasonix/desktop/internal/workspacestate"
14 "reasonix/internal/session"
15 "reasonix/internal/sessioncatalog"
16 )
17
18 func TestMetadataTopicTextFilterUsesLazyUnicodeSnapshot(t *testing.T) {
19 isolateDesktopUserDirs(t)
20 root := t.TempDir()
21 if err := addProject(root, "Text filter"); err != nil {
22 t.Fatal(err)
23 }
24 app := NewApp()
25 catalog, err := sessioncatalog.Open(t.Context(), sessioncatalog.Options{Path: filepath.Join(t.TempDir(), "catalog.sqlite"), MetadataOnly: true})
26 if err != nil {
27 t.Fatal(err)
28 }
29 app.sessionCatalog.Store(catalog)
30 t.Cleanup(func() { app.desktopSessions.readSnapshots.close(); app.stopSessionCatalog(time.Second) })
31 rows := make([]sessioncatalog.SessionRecord, 123)
32 for i := range rows {
33 title, preview := "unrelated", ""
34 if i == 121 || i == 3 {
35 title = "ÜBER 历史"
36 }
37 if i == 60 {
38 preview = "über preview"
39 }
40 rows[i] = sessioncatalog.SessionRecord{Path: filepath.Join(root, fmt.Sprintf("unread-%03d.jsonl", i)), Directory: root,
41 Scope: "project", WorkspaceRoot: root, TopicID: fmt.Sprint(i), TopicTitle: title, Preview: preview,
42 CreatedAt: int64(i + 1), LastActivityAt: int64(i + 1), OrdinaryVisible: true, Health: sessioncatalog.HealthOK}
43 if err := catalog.UpsertSession(t.Context(), rows[i]); err != nil {
44 t.Fatal(err)
45 }
46 }
47 req := ProjectTopicPageRequest{Scope: "project", WorkspaceRoot: root, Query: " üBeR ", Limit: 1}
48 first, err := app.ListProjectTopics(req)
49 if err != nil || len(first.Items) != 1 || first.Items[0].SessionPath != rows[121].Path || first.NextCursor == "" {
50 t.Fatalf("Unicode first page: %+v %v", first, err)
51 }
52 store := &app.desktopSessions.readSnapshots
53 store.mu.Lock()
54 snapshot := store.entries[first.SnapshotID].data
55 store.mu.Unlock()
56 if snapshot.readPage == nil || snapshot.count != 0 || len(snapshot.rows) != 0 || snapshot.db != nil {
57 t.Fatal("text filter materialized the history library")
58 }
59 rows[120].TopicTitle = "ÜBER newly matching"
60 if err := catalog.UpsertSession(t.Context(), rows[120]); err != nil {
61 t.Fatal(err)
62 }
63 page := first
64 for _, index := range []int{60, 3} {
65 req.Cursor = page.NextCursor
66 page, err = app.ListProjectTopics(req)
67 if err != nil || len(page.Items) != 1 || page.Items[0].SessionPath != rows[index].Path {
68 t.Fatalf("fixed text snapshot continuation: %+v %v", page, err)
69 }
70 }
71 if page.NextCursor != "" {
72 t.Fatal("filtered tail advertised more matches")
73 }
74 app.ReleaseReadSnapshot(first.SnapshotID)
75 req.Cursor, req.Query = "", strings.ToUpper(desktopSourceKey(rows[50].Path, ""))
76 identity, err := app.ListProjectTopics(req)
77 if err != nil || len(identity.Items) != 1 || identity.Items[0].SessionPath != rows[50].Path {
78 t.Fatalf("physical source key search changed: %+v %v", identity, err)
79 }
80 app.ReleaseReadSnapshot(identity.SnapshotID)
81 req.Query = "missing term"
82 empty, err := app.ListProjectTopics(req)
83 if err != nil || len(empty.Items) != 0 || empty.NextCursor != "" {
84 t.Fatalf("empty text result: %+v %v", empty, err)
85 }
86 app.ReleaseReadSnapshot(empty.SnapshotID)
87 app.setDesktopLocale("en")
88 if err := catalog.SyncMetadata(t.Context(), nil, []sessioncatalog.TopicMetadata{{Scope: "project", WorkspaceRoot: root,
89 TopicID: rows[0].TopicID, Title: defaultTopicTitle, TitleSource: topicTitleSourceAuto}}); err != nil {
90 t.Fatal(err)
91 }
92 req.Query = defaultTopicTitleEn
93 localized, err := app.ListProjectTopics(req)
94 if err != nil || len(localized.Items) != 1 || localized.Items[0].SessionPath != rows[0].Path || localized.Items[0].Label != defaultTopicTitleEn {
95 t.Fatalf("localized auto-title filter changed: %+v %v", localized, err)
96 }
97 app.ReleaseReadSnapshot(localized.SnapshotID)
98 }
99
100 func TestMetadataTopicSnapshotReadsPagesWithoutMaterializingHistory(t *testing.T) {
101 isolateDesktopUserDirs(t)
102 root := t.TempDir()
103 if err := addProject(root, "Large history"); err != nil {
104 t.Fatal(err)
105 }
106 app := NewApp()
107 catalog, err := sessioncatalog.Open(t.Context(), sessioncatalog.Options{Path: filepath.Join(t.TempDir(), "catalog.sqlite"), MetadataOnly: true})
108 if err != nil {
109 t.Fatal(err)
110 }
111 app.sessionCatalog.Store(catalog)
112 t.Cleanup(func() { app.desktopSessions.readSnapshots.close(); app.stopSessionCatalog(time.Second) })
113 rows := make([]sessioncatalog.SessionRecord, 203)
114 for i := range rows {
115 // No source files exist: this list can only succeed by reading metadata.
116 rows[i] = sessioncatalog.SessionRecord{Path: filepath.Join(root, fmt.Sprintf("old-%04d.jsonl", i)), Directory: root, Scope: "project", WorkspaceRoot: root, TopicID: "shared-topic", TopicTitle: "Shared", CreatedAt: int64(i + 1), LastActivityAt: int64(i + 1), OrdinaryVisible: true, TurnsState: sessioncatalog.TurnsUnknown, Health: sessioncatalog.HealthOK}
117 if err := catalog.UpsertSession(t.Context(), rows[i]); err != nil {
118 t.Fatal(err)
119 }
120 }
121 req := ProjectTopicPageRequest{Scope: "project", WorkspaceRoot: root, Limit: 200}
122 first, err := app.ListProjectTopics(req)
123 if err != nil || len(first.Items) != 200 || first.NextCursor == "" {
124 t.Fatalf("first page: %d %q %v", len(first.Items), first.NextCursor, err)
125 }
126 store := &app.desktopSessions.readSnapshots
127 store.mu.Lock()
128 snapshot := store.entries[first.SnapshotID].data
129 store.mu.Unlock()
130 if snapshot.readPage == nil || snapshot.count != 0 || len(snapshot.rows) != 0 || snapshot.db != nil {
131 t.Fatal("first page materialized history instead of retaining a catalog view")
132 }
133 // Concurrent activity changes ordering for a new view only.
134 rows[0].LastActivityAt = 10000
135 if err := catalog.UpsertSession(context.Background(), rows[0]); err != nil {
136 t.Fatal(err)
137 }
138 req.Cursor = first.NextCursor
139 second, err := app.ListProjectTopics(req)
140 if err != nil || len(second.Items) != 3 || second.NextCursor != "" || second.Items[2].SessionPath != rows[0].Path {
141 t.Fatalf("snapshot continuation: %+v %v", second, err)
142 }
143 req.Cursor = ""
144 fresh, err := app.ListProjectTopics(req)
145 if err != nil || fresh.Items[0].SessionPath != rows[0].Path {
146 t.Fatalf("refresh: %+v %v", fresh, err)
147 }
148 app.ReleaseReadSnapshot(first.SnapshotID)
149 if !snapshot.released || snapshot.closeRead != nil {
150 t.Fatal("released cursor retained the WAL view")
151 }
152 }
153
154 func TestMetadataTopicGroupSnapshotKeepsMembershipAndPagesLazy(t *testing.T) {
155 app, root, _ := canonicalOrganizationFixture(t, "inside", "outside")
156 catalog, err := sessioncatalog.Open(t.Context(), sessioncatalog.Options{Path: filepath.Join(t.TempDir(), "catalog.sqlite"), MetadataOnly: true})
157 if err != nil {
158 t.Fatal(err)
159 }
160 app.sessionCatalog.Store(catalog)
161 t.Cleanup(func() { app.desktopSessions.readSnapshots.close(); app.stopSessionCatalog(time.Second) })
162 workspaceID, _, err := app.ensureSessionOrganization("project", root)
163 if err != nil {
164 t.Fatal(err)
165 }
166 paths := []string{}
167 for i := range 9 {
168 path := filepath.Join(root, fmt.Sprintf("unread-%d.jsonl", i))
169 paths = append(paths, path)
170 if err := catalog.UpsertSession(t.Context(), sessioncatalog.SessionRecord{Path: path, Directory: root,
171 Scope: "project", WorkspaceRoot: root, TopicID: "shared", TopicTitle: "Shared",
172 CreatedAt: int64(i + 1), LastActivityAt: int64(i + 1), OrdinaryVisible: true, Health: sessioncatalog.HealthOK}); err != nil {
173 t.Fatal(err)
174 }
175 }
176 sourceKey := func(i int) string { return "source\x00local\x00" + desktopSourceKey(paths[i], "") }
177 _, _, err = app.workspaceRegistry().UpdateOrganization(t.Context(), workspaceID, nil, func(org *workspacestate.Organization) error {
178 org.Groups = []workspacestate.OrganizationGroup{
179 {ID: "selected", Members: []string{workspacestate.SessionKey("inside"), sourceKey(1), sourceKey(8)}},
180 {ID: "empty", Members: []string{}},
181 }
182 return nil
183 })
184 if err != nil {
185 t.Fatal(err)
186 }
187 reader := &recordingTopicInfoReader{}
188 req := ProjectTopicPageRequest{Scope: "project", WorkspaceRoot: root, GroupFilter: "group", GroupID: "selected", Limit: 1}
189 page, err := app.readProjectTopicPage(req, reader)
190 if err != nil || len(page.Items) != 1 || page.NextCursor == "" {
191 t.Fatalf("first grouped page: %+v %v", page, err)
192 }
193 if !reflect.DeepEqual(reader.ids, []string{"inside"}) {
194 t.Fatalf("group read unrelated canonical headers: %v", reader.ids)
195 }
196 store := &app.desktopSessions.readSnapshots
197 store.mu.Lock()
198 snapshot := store.entries[page.SnapshotID].data
199 store.mu.Unlock()
200 if snapshot.readPage == nil || snapshot.count != 0 || len(snapshot.rows) != 0 || snapshot.db != nil {
201 t.Fatal("group materialized the history library")
202 }
203 // A later membership change belongs to a fresh snapshot. The retained
204 // cursor must keep all three original members and their fixed order.
205 _, _, err = app.workspaceRegistry().UpdateOrganization(t.Context(), workspaceID, nil, func(org *workspacestate.Organization) error {
206 org.Groups[0].Members = []string{workspacestate.SessionKey("outside"), sourceKey(0)}
207 return nil
208 })
209 if err != nil {
210 t.Fatal(err)
211 }
212 seen := []string{projectNodeSessionKey(page.Items[0])}
213 for page.NextCursor != "" && len(seen) <= 9 {
214 req.Cursor = page.NextCursor
215 page, err = app.readProjectTopicPage(req, reader)
216 if err != nil {
217 t.Fatal(err)
218 }
219 for _, node := range page.Items {
220 seen = append(seen, projectNodeSessionKey(node))
221 }
222 }
223 want := []string{sourceKey(8), sourceKey(1), workspacestate.SessionKey("inside")}
224 if !reflect.DeepEqual(seen, want) {
225 t.Fatalf("retained membership: %v, want %v", seen, want)
226 }
227 app.ReleaseReadSnapshot(page.SnapshotID)
228 reader.ids = nil
229 req.Cursor, req.Limit = "", 50
230 fresh, err := app.readProjectTopicPage(req, reader)
231 if err != nil || len(fresh.Items) != 2 || !reflect.DeepEqual(reader.ids, []string{"outside"}) {
232 t.Fatalf("fresh group: %+v headers=%v %v", fresh, reader.ids, err)
233 }
234 app.ReleaseReadSnapshot(fresh.SnapshotID)
235 reader.ids = nil
236 req.GroupID = "empty"
237 empty, err := app.readProjectTopicPage(req, reader)
238 if err != nil || len(empty.Items) != 0 || len(reader.ids) != 0 {
239 t.Fatalf("empty group: %+v headers=%v %v", empty, reader.ids, err)
240 }
241 app.ReleaseReadSnapshot(empty.SnapshotID)
242 req.GroupFilter, req.GroupID = "ungrouped", ""
243 ungrouped, err := app.readProjectTopicPage(req, reader)
244 if err != nil || len(ungrouped.Items) != 9 || !reflect.DeepEqual(reader.ids, []string{"inside"}) {
245 t.Fatalf("ungrouped: %+v headers=%v %v", ungrouped, reader.ids, err)
246 }
247 for _, node := range ungrouped.Items {
248 if key := projectNodeSessionKey(node); key == sourceKey(0) || key == workspacestate.SessionKey("outside") {
249 t.Fatalf("grouped member appeared ungrouped: %s", key)
250 }
251 }
252 app.ReleaseReadSnapshot(ungrouped.SnapshotID)
253 req.GroupFilter, req.GroupID = "group", "deleted"
254 if _, err := app.readProjectTopicPage(req, reader); err == nil {
255 t.Fatal("deleted group silently returned all history")
256 }
257 }
258
259 func TestMetadataTopicSnapshotAllViewWithGroupsStaysLazy(t *testing.T) {
260 app, root, _ := canonicalOrganizationFixture(t, "pinned", "not-pinned")
261 catalog, err := sessioncatalog.Open(t.Context(), sessioncatalog.Options{Path: filepath.Join(t.TempDir(), "catalog.sqlite"), MetadataOnly: true})
262 if err != nil {
263 t.Fatal(err)
264 }
265 app.sessionCatalog.Store(catalog)
266 t.Cleanup(func() { app.desktopSessions.readSnapshots.close(); app.stopSessionCatalog(time.Second) })
267 workspaceID, _, err := app.ensureSessionOrganization("project", root)
268 if err != nil {
269 t.Fatal(err)
270 }
271 _, _, err = app.workspaceRegistry().UpdateOrganization(t.Context(), workspaceID, nil, func(org *workspacestate.Organization) error {
272 org.Groups = []workspacestate.OrganizationGroup{{ID: "g", Title: "Group", Members: []string{workspacestate.SessionKey("pinned")}}}
273 return nil
274 })
275 if err != nil {
276 t.Fatal(err)
277 }
278 pin := true
279 if err := app.workspaceRegistry().UpdatePresentation(t.Context(), []string{"pinned"}, nil, &pin); err != nil {
280 t.Fatal(err)
281 }
282 for i := range 20 {
283 if err := catalog.UpsertSession(t.Context(), sessioncatalog.SessionRecord{
284 Path: filepath.Join(root, fmt.Sprintf("unread-%d.jsonl", i)), Directory: root,
285 Scope: "project", WorkspaceRoot: root, TopicID: fmt.Sprintf("topic-%d", i), TopicTitle: "Legacy",
286 CreatedAt: 1, LastActivityAt: 1, OrdinaryVisible: true, Health: sessioncatalog.HealthOK,
287 }); err != nil {
288 t.Fatal(err)
289 }
290 }
291 req := ProjectTopicPageRequest{Scope: "project", WorkspaceRoot: root, GroupFilter: "all", Limit: 2}
292 page, err := app.ListProjectTopics(req)
293 if err != nil || len(page.Items) != 2 || page.NextCursor == "" {
294 t.Fatalf("all view: %+v %v", page, err)
295 }
296 store := &app.desktopSessions.readSnapshots
297 store.mu.Lock()
298 lazy := store.entries[page.SnapshotID].data.readPage != nil
299 store.mu.Unlock()
300 if !lazy {
301 t.Fatal("an unrelated group forced the all view to materialize legacy history")
302 }
303 app.ReleaseReadSnapshot(page.SnapshotID)
304 timed := req
305 timed.TimeFilter, timed.Limit = "24h", 1
306 recent, err := app.ListProjectTopics(timed)
307 if err != nil || len(recent.Items) != 1 || recent.NextCursor == "" {
308 t.Fatalf("time-filtered first page: %+v %v", recent, err)
309 }
310 store.mu.Lock()
311 lazy = store.entries[recent.SnapshotID].data.readPage != nil
312 store.mu.Unlock()
313 if !lazy {
314 t.Fatal("time filter materialized the history library")
315 }
316 timed.Cursor = recent.NextCursor
317 last, err := app.ListProjectTopics(timed)
318 if err != nil || len(last.Items) != 1 || last.NextCursor != "" || last.Items[0].Session == nil {
319 t.Fatalf("time filter admitted old legacy metadata: %+v %v", last, err)
320 }
321 app.ReleaseReadSnapshot(recent.SnapshotID)
322 state, err := app.workspaceRegistry().LoadProjection(t.Context())
323 if err != nil {
324 t.Fatal(err)
325 }
326 // Shell construction uses the same lazy adapter, including an unmigrated
327 // automatic-order preference. It must not enumerate unpinned histories.
328 pins, err := app.projectTopicsFromProjection(ProjectTopicPageRequest{Scope: "project", WorkspaceRoot: root, Limit: 2, pinnedOnly: true}, state, workspacestate.NewWorkspaceIndex(state), workspaceID, workspacestate.Organization{}, &desktopProject{})
329 if err != nil || len(pins.Items) != 1 || pins.Items[0].Session.SessionID != "pinned" {
330 t.Fatalf("pinned shell: %+v %v", pins, err)
331 }
332 store.mu.Lock()
333 lazy = store.entries[pins.SnapshotID].data.readPage != nil
334 store.mu.Unlock()
335 if !lazy {
336 t.Fatal("pinned shell bypassed the lazy catalog adapter")
337 }
338 app.ReleaseReadSnapshot(pins.SnapshotID)
339 // Independently assert which canonical headers are admitted to this read.
340 reader := &recordingTopicInfoReader{}
341 admitted, err := app.readProjectTopicPage(ProjectTopicPageRequest{Scope: "project", WorkspaceRoot: root, pinnedOnly: true}, reader)
342 if err != nil || len(reader.ids) != 1 || reader.ids[0] != "pinned" {
343 t.Fatalf("pinned metadata admission: %v err=%v", reader.ids, err)
344 }
345 app.ReleaseReadSnapshot(admitted.SnapshotID)
346 }
347
348 type recordingTopicInfoReader struct {
349 ids []string
350 onStat func()
351 }
352
353 func (r *recordingTopicInfoReader) Stat(_ context.Context, ref session.SessionRef) (session.SessionInfo, error) {
354 r.ids = append(r.ids, ref.SessionID)
355 if r.onStat != nil {
356 r.onStat()
357 }
358 return session.SessionInfo{SessionID: ref.SessionID}, nil
359 }
360
361 func TestMetadataTopicSnapshotFencesTopicChangeAfterWALCapture(t *testing.T) {
362 app, root, _ := canonicalOrganizationFixture(t, "current")
363 catalog, err := sessioncatalog.Open(t.Context(), sessioncatalog.Options{Path: filepath.Join(t.TempDir(), "catalog.sqlite"), MetadataOnly: true})
364 if err != nil {
365 t.Fatal(err)
366 }
367 app.sessionCatalog.Store(catalog)
368 t.Cleanup(func() { app.desktopSessions.readSnapshots.close(); app.stopSessionCatalog(time.Second) })
369 record := sessioncatalog.SessionRecord{Path: filepath.Join(root, "old.jsonl"), Directory: root, Scope: "project", WorkspaceRoot: root, TopicID: "before", TopicTitle: "Before", CreatedAt: 1, LastActivityAt: 1, Health: sessioncatalog.HealthOK, OrdinaryVisible: true}
370 if err := catalog.UpsertSession(t.Context(), record); err != nil {
371 t.Fatal(err)
372 }
373 if err := catalog.SyncMetadata(t.Context(), nil, []sessioncatalog.TopicMetadata{{Scope: "project", WorkspaceRoot: root, TopicID: "before", Title: "Before", Pinned: true}}); err != nil {
374 t.Fatal(err)
375 }
376 reader := &recordingTopicInfoReader{onStat: func() {
377 // Header reads happen after the catalog read transaction is captured.
378 // Move the physical source while that older view is still retained.
379 record.TopicID, record.TopicTitle = "after", "After"
380 if err := catalog.UpsertSession(t.Context(), record); err != nil {
381 t.Fatal(err)
382 }
383 }}
384 req := ProjectTopicPageRequest{Scope: "project", WorkspaceRoot: root, Limit: 1}
385 first, err := app.readProjectTopicPage(req, reader)
386 if err != nil || len(first.Items) != 1 || first.Items[0].TopicID != "before" || first.NextCursor == "" {
387 t.Fatalf("captured first page: %+v %v", first, err)
388 }
389 defer app.ReleaseReadSnapshot(first.SnapshotID)
390 req.Cursor = first.NextCursor
391 _, err = app.readProjectTopicPage(req, reader)
392 var stale *SessionOperationError
393 if !errors.As(err, &stale) || stale.Code != "stale_cursor" {
394 t.Fatalf("live identity replaced the frozen source fence: %v", err)
395 }
396 }
397
397 lines GO