返回 DeepSeek-Reasonix
query.go
根目录 / internal / session / query.go
1 package session
2
3 import (
4 "context"
5 "fmt"
6 "path/filepath"
7 "strings"
8 "sync"
9 "time"
10
11 "reasonix/internal/provider"
12 )
13
14 // Query is the read model shared by desktop, CLI, Serve, ACP and bots. It uses
15 // an attached runtime when one exists and otherwise opens only a read handle;
16 // querying cold history never constructs an Agent or acquires writer ownership.
17 type Query struct {
18 hostID string
19 persistence SessionPersistence
20 service *Service
21 rebuildMu sync.Mutex
22 rebuilding map[string]struct{}
23 metadataQueue []metadataRebuildTask
24 metadataWorkers int
25 metadataFailures map[string]error
26 generation map[string]uint64
27 rebuildCtx context.Context
28 rebuildStop context.CancelFunc
29 rebuildWG sync.WaitGroup
30 closed bool
31 slots *rebuildSlots
32 indexMu sync.Mutex
33 indexLocks map[string]*sync.Mutex
34 contentMu sync.Mutex
35 contentGrants map[string]time.Time
36 searchMu sync.Mutex
37 searchBuilds map[string]*searchPreparation
38 historyMu sync.Mutex
39 historyBuilds map[string]*historyPreparation
40 }
41
42 func (s *Service) Query() *Query {
43 if s == nil {
44 return nil
45 }
46 return s.query
47 }
48
49 func newQuery(hostID string, persistence SessionPersistence, service *Service) *Query {
50 rebuildCtx, rebuildStop := context.WithCancel(context.Background())
51 query := &Query{
52 hostID: hostID, persistence: persistence, service: service,
53 rebuilding: map[string]struct{}{}, generation: map[string]uint64{}, rebuildCtx: rebuildCtx,
54 metadataFailures: map[string]error{},
55 rebuildStop: rebuildStop, slots: newRebuildSlots(2),
56 indexLocks: map[string]*sync.Mutex{},
57 contentGrants: map[string]time.Time{},
58 searchBuilds: map[string]*searchPreparation{},
59 historyBuilds: map[string]*historyPreparation{},
60 }
61 return query
62 }
63
64 func contentGrantKey(sessionID, storageGeneration, digest string, bytes int64, indexDigest string) string {
65 return sessionID + "\x00" + storageGeneration + "\x00" + digest + "\x00" + fmt.Sprint(bytes) + "\x00" + indexDigest
66 }
67
68 func (q *Query) storageGeneration(sessionID string) string {
69 filesystem, ok := q.persistence.(*FilesystemPersistence)
70 if !ok {
71 return ""
72 }
73 dir := filepath.Join(filesystem.Root, sessionID)
74 manifest, err := readStoredManifest(filepath.Join(dir, "manifest.json"))
75 if err != nil {
76 return ""
77 }
78 identity, err := readStorageIdentity(dir, manifest)
79 if err != nil {
80 return ""
81 }
82 return identity.Generation
83 }
84
85 func (q *Query) authorizeContentForGeneration(sessionID, generation, digest string, bytes int64, indexDigest string) {
86 if generation == "" {
87 return
88 }
89 q.contentMu.Lock()
90 defer q.contentMu.Unlock()
91 now := time.Now()
92 for key, expiry := range q.contentGrants {
93 if !expiry.After(now) {
94 delete(q.contentGrants, key)
95 }
96 }
97 q.contentGrants[contentGrantKey(sessionID, generation, digest, bytes, indexDigest)] = now.Add(15 * time.Minute)
98 }
99
100 func (q *Query) contentAuthorized(sessionID, digest string, bytes int64, indexDigest string) bool {
101 generation := q.storageGeneration(sessionID)
102 if generation == "" {
103 return false
104 }
105 q.contentMu.Lock()
106 defer q.contentMu.Unlock()
107 key := contentGrantKey(sessionID, generation, digest, bytes, indexDigest)
108 expiry, ok := q.contentGrants[key]
109 if !ok || !expiry.After(time.Now()) {
110 delete(q.contentGrants, key)
111 return false
112 }
113 return true
114 }
115
116 func (q *Query) projectionLock(kind, sessionID string) *sync.Mutex {
117 key := kind + "\x00" + sessionID
118 q.indexMu.Lock()
119 defer q.indexMu.Unlock()
120 lock := q.indexLocks[key]
121 if lock == nil {
122 lock = &sync.Mutex{}
123 q.indexLocks[key] = lock
124 }
125 return lock
126 }
127
128 // Close stops catalog work owned by this query. Individual List callers do not
129 // own shared rebuilds, so cancelling one request never cancels work another
130 // caller may use; the host query lifetime is the cancellation boundary.
131 func (q *Query) Close() {
132 if q == nil {
133 return
134 }
135 q.rebuildMu.Lock()
136 if !q.closed {
137 q.closed = true
138 if q.rebuildStop != nil {
139 q.rebuildStop()
140 }
141 }
142 q.rebuildMu.Unlock()
143 q.rebuildWG.Wait()
144 }
145
146 func (q *Query) Snapshot(ctx context.Context, ref SessionRef) (Snapshot, error) {
147 if q == nil || q.persistence == nil {
148 return Snapshot{}, fmt.Errorf("session: nil session query")
149 }
150 if err := ref.validate(q.hostID); err != nil {
151 return Snapshot{}, err
152 }
153 if q.service != nil {
154 if runtime, ok := q.service.Runtime(ref); ok {
155 return runtime.Session().Snapshot(), nil
156 }
157 }
158 handle, err := q.persistence.Open(ref.SessionID, ReadOnly)
159 if err != nil {
160 return Snapshot{}, err
161 }
162 defer handle.Close(context.WithoutCancel(ctx))
163 projection := Projection{}
164 var cursor uint64
165 for {
166 page, readErr := handle.Read(ctx, cursor, 1000)
167 if readErr != nil {
168 return Snapshot{}, readErr
169 }
170 for _, commit := range page.Commits {
171 if err := applyProjectionCommit(&projection, commit); err != nil {
172 return Snapshot{}, err
173 }
174 }
175 if !page.Truncated {
176 break
177 }
178 if page.Next <= cursor {
179 return Snapshot{}, fmt.Errorf("%w: cold history cursor did not advance", ErrDamagedStore)
180 }
181 cursor = page.Next
182 }
183 sequence := projection.CommittedSequence
184 return Snapshot{EventSequence: sequence, DurableSequence: sequence, PersistenceStatus: PersistenceReady, Projection: projection}, nil
185 }
186
187 func (q *Query) History(ctx context.Context, ref SessionRef) ([]provider.Message, error) {
188 snapshot, err := q.Snapshot(ctx, ref)
189 if err != nil {
190 return nil, err
191 }
192 return append([]provider.Message(nil), snapshot.Projection.Messages...), nil
193 }
194
195 // Stat returns one header-backed metadata observation without opening event
196 // bodies. Live projection state overlays the disposable cache, matching List.
197 func (q *Query) Stat(ctx context.Context, ref SessionRef) (SessionInfo, error) {
198 if q == nil || q.persistence == nil {
199 return SessionInfo{}, fmt.Errorf("session: nil session query")
200 }
201 if err := ref.validate(q.hostID); err != nil {
202 return SessionInfo{}, err
203 }
204 info, err := q.persistence.Stat(ctx, ref.SessionID)
205 if err != nil {
206 return SessionInfo{}, err
207 }
208 q.enrichInfo(&info)
209 return info, nil
210 }
211
212 // ResolveSessionID turns a caller-selected opaque ID into a server-observed
213 // SessionRef. The requested value is used only for equality; every identity
214 // returned to filesystem-backed readers comes from the persistence catalog.
215 func (q *Query) ResolveSessionID(ctx context.Context, requested string) (SessionRef, error) {
216 if q == nil || q.persistence == nil {
217 return SessionRef{}, fmt.Errorf("session: nil session query")
218 }
219 candidate := strings.TrimSpace(requested)
220 if err := validateSessionID(candidate); err != nil {
221 return SessionRef{}, err
222 }
223 cursor := ""
224 for {
225 page, err := q.persistence.List(ctx, cursor, 100)
226 if err != nil {
227 return SessionRef{}, err
228 }
229 for _, info := range page.Sessions {
230 if info.SessionID == candidate {
231 return SessionRef{HostID: q.hostID, SessionID: info.SessionID}, nil
232 }
233 }
234 if page.NextCursor == "" {
235 return SessionRef{}, fmt.Errorf("%w: %s", ErrSessionNotFound, candidate)
236 }
237 if page.NextCursor <= cursor {
238 return SessionRef{}, fmt.Errorf("%w: catalog cursor did not advance", ErrDamagedStore)
239 }
240 cursor = page.NextCursor
241 }
242 }
243
244 func (q *Query) List(ctx context.Context, cursor string, limit int) (SessionPage, error) {
245 if q == nil || q.persistence == nil {
246 return SessionPage{}, fmt.Errorf("session: nil session query")
247 }
248 page, err := q.persistence.List(ctx, cursor, limit)
249 if err != nil {
250 return SessionPage{}, err
251 }
252 for i := range page.Sessions {
253 q.enrichInfo(&page.Sessions[i])
254 }
255 return page, nil
256 }
257
258 func (q *Query) enrichInfo(info *SessionInfo) {
259 info.Ref = SessionRef{HostID: q.hostID, SessionID: info.SessionID}
260 if info.Error != "" {
261 return
262 }
263 if q.service != nil {
264 if runtime, ok := q.service.Runtime(info.Ref); ok {
265 applyCatalogMetadata(info, runtime.Session().CatalogMetadata())
266 return
267 }
268 }
269 if info.Codec == Codec && info.MetadataStatus != MetadataReady {
270 q.rebuildMu.Lock()
271 failure := q.metadataFailures[info.SessionID]
272 q.rebuildMu.Unlock()
273 if failure != nil {
274 info.MetadataStatus, info.Error = MetadataFailed, failure.Error()
275 return
276 }
277 q.scheduleMetadataRebuild(info.SessionID)
278 }
279 }
280
281 func applyCatalogMetadata(info *SessionInfo, metadata catalogMetadata) {
282 info.Title, info.TitleSequence = metadata.Title, metadata.TitleSequence
283 info.ModelRef, info.ModelIdentity = metadata.ModelRef, metadata.ModelIdentity
284 info.Turns, info.Preview, info.MetadataStatus = metadata.Turns, metadata.Preview, MetadataReady
285 info.EventSequence, info.ResultSequence = metadata.Sequence, metadata.ResultSequence
286 }
287
288 func (q *Query) scheduleMetadataRebuild(sessionID string) {
289 if _, ok := q.persistence.(*FilesystemPersistence); !ok {
290 return
291 }
292 q.rebuildMu.Lock()
293 if q.closed {
294 q.rebuildMu.Unlock()
295 return
296 }
297 if _, exists := q.rebuilding[sessionID]; exists {
298 q.rebuildMu.Unlock()
299 return
300 }
301 q.rebuilding[sessionID] = struct{}{}
302 delete(q.metadataFailures, sessionID)
303 generation := q.generation[sessionID]
304 q.metadataQueue = append(q.metadataQueue, metadataRebuildTask{sessionID, generation})
305 if q.metadataWorkers < 2 {
306 q.metadataWorkers++
307 q.rebuildWG.Add(1)
308 go q.runMetadataRebuilds()
309 }
310 q.rebuildMu.Unlock()
311 }
312
313 type metadataRebuildTask struct {
314 sessionID string
315 generation uint64
316 }
317
318 // Keep a deduplicated ID queue, not one goroutine per session. Workers wait
319 // behind user history/recovery work and continue without another List call.
320 func (q *Query) runMetadataRebuilds() {
321 defer q.rebuildWG.Done()
322 for {
323 q.rebuildMu.Lock()
324 if q.closed || len(q.metadataQueue) == 0 {
325 q.metadataWorkers--
326 if q.closed {
327 q.metadataQueue = nil
328 clear(q.rebuilding)
329 }
330 q.rebuildMu.Unlock()
331 return
332 }
333 task := q.metadataQueue[0]
334 q.metadataQueue[0] = metadataRebuildTask{}
335 q.metadataQueue = q.metadataQueue[1:]
336 q.rebuildMu.Unlock()
337 if err := q.slots.acquire(q.rebuildCtx, rebuildPriorityPrefetch); err != nil {
338 continue
339 }
340 err := q.rebuildCatalogMetadata(task.sessionID, task.generation)
341 q.slots.release()
342 q.rebuildMu.Lock()
343 if err != nil && q.generation[task.sessionID] == task.generation {
344 q.metadataFailures[task.sessionID] = err
345 }
346 delete(q.rebuilding, task.sessionID)
347 q.rebuildMu.Unlock()
348 }
349 }
350
351 // invalidateCatalog fences every metadata task scheduled for an older session
352 // incarnation. Service calls it before deleting the directory, so a delayed
353 // task cannot publish a cache entry that makes the deleted session reappear.
354 func (q *Query) invalidateCatalog(sessionID string) {
355 if q == nil {
356 return
357 }
358 q.rebuildMu.Lock()
359 q.generation[sessionID]++
360 delete(q.metadataFailures, sessionID)
361 q.rebuildMu.Unlock()
362 }
363
364 func (q *Query) rebuildCatalogMetadata(sessionID string, generation uint64) error {
365 filesystem, ok := q.persistence.(*FilesystemPersistence)
366 if !ok {
367 return nil
368 }
369 handle, err := q.persistence.Open(sessionID, ReadOnly)
370 if err != nil {
371 return err
372 }
373 defer handle.Close(context.WithoutCancel(q.rebuildCtx))
374 sessionDir := filepath.Join(filesystem.Root, sessionID)
375 cacheDir := filepath.Join(filesystem.Root, ".query-cache", filepath.Base(sessionID))
376 manifest, err := readManifest(filepath.Join(sessionDir, "manifest.json"))
377 if err != nil {
378 return err
379 }
380 metadata, err := reduceCatalogMetadata(q.rebuildCtx, handle, manifest)
381 if err != nil {
382 return err
383 }
384 q.rebuildMu.Lock()
385 defer q.rebuildMu.Unlock()
386 if q.rebuildCtx.Err() != nil || q.generation[sessionID] != generation {
387 return nil
388 }
389 // Hold the generation boundary through publication. Deletion invalidates
390 // before it moves the directory, so it either wins first or waits until this
391 // exact-incarnation cache is completely written and then removes it.
392 return writeCatalogMetadataForSession(cacheDir, sessionDir, metadata)
393 }
394
394 lines GO