返回 DeepSeek-Reasonix
connection_pool_test.go
根目录 / internal / sessioncatalog / connection_pool_test.go
1 package sessioncatalog
2
3 // Guards for connection-pool starvation: every catalog API must finish on a
4 // pool of one, so no path can hold a connection while waiting for another.
5
6 import (
7 "context"
8 "fmt"
9 "os"
10 "path/filepath"
11 "testing"
12 "time"
13
14 "reasonix/internal/agent"
15 )
16
17 // A memory-mode catalog pools a single connection, so hydrating a topic's
18 // sessions from inside the open topic cursor deadlocks until the caller's
19 // context expires — with the desktop's boot context, forever.
20 func TestListTopicsHydratesSessionsOffTheTopicCursor(t *testing.T) {
21 t.Parallel()
22 ctx := context.Background()
23 catalog, err := Open(ctx, Options{InMemory: true, DisableRepair: true})
24 if err != nil {
25 t.Fatal(err)
26 }
27 t.Cleanup(func() { _ = catalog.Close(context.Background()) })
28 for _, topicID := range []string{"topic-a", "topic-b"} {
29 if err := catalog.UpsertSession(ctx, SessionRecord{
30 Path: filepath.Join("/s", topicID+".jsonl"), Directory: "/s", Scope: "global",
31 TopicID: topicID, TopicTitle: topicID, Turns: 1, TurnsState: TurnsValid,
32 Health: HealthOK, LastActivityAt: 1,
33 }); err != nil {
34 t.Fatal(err)
35 }
36 }
37
38 // A regression cannot hang the suite: it starves the pool and this deadline
39 // turns the deadlock into a failed call.
40 listCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
41 defer cancel()
42 page, err := catalog.ListTopics(listCtx, TopicPageRequest{Scope: "global", Limit: MaxLimit})
43 if err != nil {
44 t.Fatalf("ListTopics on a single-connection catalog: %v", err)
45 }
46 if len(page.Items) != 2 {
47 t.Fatalf("topics = %d, want both hydrated topics", len(page.Items))
48 }
49 for _, item := range page.Items {
50 if len(item.Sessions) != 1 {
51 t.Fatalf("topic %q sessions = %d, want 1", item.TopicID, len(item.Sessions))
52 }
53 }
54 }
55
56 // The disk pool is four connections and every in-flight ListTopics pins one for
57 // its topic cursor, so four concurrent sidebar reads used to leave the nested
58 // session queries with nothing left to acquire and no deadline to break out of.
59 func TestListTopicsSurvivesReadersAtThePoolLimit(t *testing.T) {
60 t.Parallel()
61 ctx := context.Background()
62 catalog, err := Open(ctx, Options{Path: filepath.Join(t.TempDir(), "catalog.sqlite"), DisableRepair: true})
63 if err != nil {
64 t.Fatal(err)
65 }
66 t.Cleanup(func() { _ = catalog.Close(context.Background()) })
67 for i := range 50 {
68 topicID := fmt.Sprintf("topic-%02d", i)
69 if err := catalog.UpsertSession(ctx, SessionRecord{
70 Path: filepath.Join("/s", topicID+".jsonl"), Directory: "/s", Scope: "global",
71 TopicID: topicID, TopicTitle: topicID, Turns: 1, TurnsState: TurnsValid,
72 Health: HealthOK, LastActivityAt: int64(i + 1),
73 }); err != nil {
74 t.Fatal(err)
75 }
76 }
77
78 listCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
79 defer cancel()
80 const readers = 8
81 results := make(chan error, readers)
82 for range readers {
83 go func() {
84 page, err := catalog.ListTopics(listCtx, TopicPageRequest{Scope: "global", Limit: MaxLimit})
85 if err == nil && len(page.Items) != 50 {
86 err = fmt.Errorf("topics = %d, want 50", len(page.Items))
87 }
88 results <- err
89 }()
90 }
91 for range readers {
92 if err := <-results; err != nil {
93 t.Fatalf("concurrent ListTopics: %v", err)
94 }
95 }
96 }
97
98 // A memory-mode catalog pools exactly one connection, so any API that holds a
99 // connection while asking the pool for another deadlocks here and nowhere else
100 // until production saturates a disk pool. This is the general guard: it fails
101 // on the API that reintroduces the nesting, not on ListTopics specifically.
102 func TestCatalogAPIsSurviveASingleConnectionPool(t *testing.T) {
103 t.Parallel()
104 ctx := context.Background()
105 dir := t.TempDir()
106 path := filepath.Join(dir, "session.jsonl")
107 if err := os.WriteFile(path, []byte(`{"role":"user","content":"hi"}`+"\n"), 0o600); err != nil {
108 t.Fatal(err)
109 }
110 if err := agent.SaveBranchMeta(path, agent.BranchMeta{
111 Scope: "project", WorkspaceRoot: "/workspace", TopicID: "topic-1",
112 TopicTitle: "Topic", SchemaVersion: agent.BranchMetaCountsVersion, Turns: 1,
113 }); err != nil {
114 t.Fatal(err)
115 }
116 catalog, err := Open(ctx, Options{InMemory: true, DisableRepair: true})
117 if err != nil {
118 t.Fatal(err)
119 }
120 t.Cleanup(func() { _ = catalog.Close(context.Background()) })
121 target := DirectoryTarget{Path: dir, Scope: "project", WorkspaceRoot: "/workspace"}
122 key := TopicKey{Scope: "project", WorkspaceRoot: "/workspace", TopicID: "topic-1"}
123
124 // One deadline across the whole surface: a regression surfaces as this
125 // step's error instead of a hung test binary.
126 call, cancel := context.WithTimeout(ctx, 30*time.Second)
127 defer cancel()
128 steps := []struct {
129 name string
130 run func() error
131 }{
132 {"ReconcileDirectory", func() error { return catalog.ReconcileDirectory(call, target) }},
133 {"IndexSessionPath", func() error { return catalog.IndexSessionPath(call, target, path) }},
134 {"SyncMetadata", func() error {
135 return catalog.SyncMetadata(call,
136 []ProjectRecord{{Scope: "project", WorkspaceRoot: "/workspace", Title: "W"}},
137 []TopicMetadata{{Scope: "project", WorkspaceRoot: "/workspace", TopicID: "topic-1", Title: "Topic"}})
138 }},
139 {"ListTopics", func() error {
140 page, err := catalog.ListTopics(call, TopicPageRequest{Scope: "project", WorkspaceRoot: "/workspace", Limit: MaxLimit})
141 if err == nil && len(page.Items) != 1 {
142 return fmt.Errorf("topics = %d, want 1", len(page.Items))
143 }
144 return err
145 }},
146 {"GetTopic", func() error {
147 topic, ok, err := catalog.GetTopic(call, key)
148 if err == nil && (!ok || len(topic.Sessions) != 1) {
149 return fmt.Errorf("topic ok=%v sessions=%d, want one session", ok, len(topic.Sessions))
150 }
151 return err
152 }},
153 {"ListSessions", func() error {
154 page, err := catalog.ListSessions(call, SessionPageRequest{Scope: "project", WorkspaceRoot: "/workspace", Limit: MaxLimit})
155 if err == nil && len(page.Items) != 1 {
156 return fmt.Errorf("sessions = %d, want 1", len(page.Items))
157 }
158 return err
159 }},
160 {"GetSession", func() error {
161 _, ok, err := catalog.GetSession(call, path)
162 if err != nil {
163 return fmt.Errorf("get session: %w", err)
164 }
165 if !ok {
166 return fmt.Errorf("session %q missing from catalog", path)
167 }
168 return nil
169 }},
170 {"RemoveSession", func() error { return catalog.RemoveSession(call, path, "test") }},
171 }
172 for _, step := range steps {
173 if err := step.run(); err != nil {
174 t.Fatalf("%s on a single-connection catalog: %v", step.name, err)
175 }
176 }
177 }
178
178 lines GO