返回 DeepSeek-Reasonix
removal.go
根目录 / internal / sessioncatalog / removal.go
1 package sessioncatalog
2
3 import (
4 "context"
5 "database/sql"
6 "errors"
7 "path/filepath"
8 "time"
9 )
10
11 // RemoveSession records a tombstone before any mutex wait so archived paths
12 // stop being queryable immediately. SQLite deletion retries asynchronously.
13 func (c *Catalog) RemoveSession(ctx context.Context, path, reason string) error {
14 if c == nil || c.db == nil {
15 return nil
16 }
17 path = cleanCatalogAccessPath(path)
18 if path == "" {
19 return nil
20 }
21 pathKey := c.pathKey(path)
22 // Immediate query overlay: ListTopics/ListSessions/GetSession filter this
23 // map even when the durable DELETE has not committed yet.
24 removalSequence := c.mutationSeq.Add(1)
25 c.removedPaths.Store(pathKey, removalSequence)
26 c.writeMu.Lock()
27 queueKey := queuePathKey(path)
28 if queued, ok := c.writeQueued[queueKey]; ok && queued.enqueueSequence <= removalSequence {
29 delete(c.writeQueued, queueKey)
30 }
31 c.writeMu.Unlock()
32 c.pathQueueMu.Lock()
33 if queued, ok := c.pathQueued.Load(queueKey); ok && queued.(sessionPathRequest).sequence <= removalSequence {
34 c.pathQueued.CompareAndDelete(queueKey, queued)
35 }
36 c.pathQueueMu.Unlock()
37 c.repairQueued.Delete(pathKey)
38 // Wake listeners without SQLite. Equal revision identifies an overlay change;
39 // empty roots refresh every expanded folder without querying the busy DB for
40 // workspace_root.
41 if c.opts.OnRevision != nil {
42 c.opts.OnRevision(c.revision.Load(), []string{}, reason)
43 }
44
45 if err := c.tryApplySessionRemoval(ctx, path, reason); err != nil {
46 c.scheduleSessionRemovalRetry(path, reason)
47 // Overlay already hides the row. Busy locks and short caller contexts
48 // are not interactive failures — durable DELETE is retried in the
49 // background.
50 if errors.Is(err, errSessionRemovalBusy) ||
51 errors.Is(err, context.Canceled) ||
52 errors.Is(err, context.DeadlineExceeded) {
53 return nil
54 }
55 return err
56 }
57 return nil
58 }
59
60 var errSessionRemovalBusy = errors.New("session catalog removal busy")
61
62 func (c *Catalog) scheduleSessionRemovalRetry(path, reason string) {
63 if c == nil || c.workerCtx == nil {
64 return
65 }
66 c.workers.Go(func() {
67 select {
68 case <-c.stop:
69 return
70 case <-c.workerCtx.Done():
71 return
72 case <-time.After(25 * time.Millisecond):
73 }
74 ctx, cancel := context.WithTimeout(c.workerCtx, 5*time.Second)
75 defer cancel()
76 // Re-check tombstone: a recreate may have cleared it.
77 if _, removed := c.removedPaths.Load(c.pathKey(path)); !removed {
78 return
79 }
80 // Blocking apply is fine on the background worker.
81 _ = c.applySessionRemovalLocked(ctx, path, reason+"-retry", true)
82 })
83 }
84
85 // tryApplySessionRemoval attempts a non-blocking durable delete. When directory
86 // or mutation locks are held by reconcile/write, returns errSessionRemovalBusy
87 // so the caller can keep the tombstone overlay and retry asynchronously.
88 func (c *Catalog) tryApplySessionRemoval(ctx context.Context, path, reason string) error {
89 return c.applySessionRemovalLocked(ctx, path, reason, false)
90 }
91
92 // applySessionRemovalLocked performs the durable SQLite delete. When blocking
93 // is false, TryLock is used so interactive RemoveSession never waits on mutexes
94 // after the tombstone is already query-visible.
95 func (c *Catalog) applySessionRemovalLocked(ctx context.Context, path, reason string, blocking bool) error {
96 if ctx == nil {
97 ctx = context.Background()
98 }
99 // Serialize an authoritative removal with directory reconciliation. Without
100 // this boundary a scan that captured the old path just before an archive
101 // could clear the tombstone and reinsert the stale projection afterwards.
102 directoryLock := c.directoryLock(filepath.Dir(path))
103 if blocking {
104 directoryLock.Lock()
105 } else if !directoryLock.TryLock() {
106 return errSessionRemovalBusy
107 }
108 defer directoryLock.Unlock()
109 if blocking {
110 c.mutationMu.Lock()
111 } else if !c.mutationMu.TryLock() {
112 return errSessionRemovalBusy
113 }
114 defer c.mutationMu.Unlock()
115 // Prefer a live worker context when the caller deadline already expired
116 // (desktop RemoveSession uses ~150ms).
117 sqlCtx := ctx
118 var cancel context.CancelFunc
119 if ctx.Err() != nil && c.workerCtx != nil {
120 sqlCtx, cancel = context.WithTimeout(c.workerCtx, 5*time.Second)
121 defer cancel()
122 }
123 tx, err := c.db.BeginTx(sqlCtx, nil)
124 if err != nil {
125 return err
126 }
127 var key TopicKey
128 pathKey := c.pathKey(path)
129 err = tx.QueryRowContext(sqlCtx, `SELECT scope,workspace_root,workspace_root_key,topic_id FROM catalog_sessions WHERE path_key=?`, pathKey).
130 Scan(&key.Scope, &key.WorkspaceRoot, &key.workspaceKey, &key.TopicID)
131 if err != nil && !errors.Is(err, sql.ErrNoRows) {
132 _ = tx.Rollback()
133 return err
134 }
135 if _, err := tx.ExecContext(sqlCtx, `DELETE FROM catalog_sessions WHERE path_key=?`, pathKey); err != nil {
136 _ = tx.Rollback()
137 return err
138 }
139 if key.TopicID != "" {
140 if err := c.recomputeTopic(sqlCtx, tx, key); err != nil {
141 _ = tx.Rollback()
142 return err
143 }
144 }
145 revision, err := bumpRevision(sqlCtx, tx)
146 if err != nil {
147 _ = tx.Rollback()
148 return err
149 }
150 if err := tx.Commit(); err != nil {
151 return err
152 }
153 c.publishRevision(revision, []string{key.WorkspaceRoot}, reason)
154 c.refreshCounts(sqlCtx)
155 return nil
156 }
157
158 func (c *Catalog) pathRemoved(path string) bool {
159 return c.pathRemovedKey("", path)
160 }
161
162 func (c *Catalog) pathRemovedKey(key, path string) bool {
163 if c == nil {
164 return false
165 }
166 if key == "" {
167 key = c.pathKey(path)
168 }
169 _, removed := c.removedPaths.Load(key)
170 return removed
171 }
172
172 lines GO