Skip to content

Commit 0c80a37

Browse files
committed
Replace scan-all watcher with explicit watch list to fix #49 OOM
Watcher no longer polls every indexed project via ListProjects(). Instead, projects are added to an explicit watch list on index/auto-index and removed on delete. Cross-project tool calls (search_graph, trace_call_path, etc.) touch the watch list so referenced projects stay fresh.
1 parent ba626aa commit 0c80a37

File tree

5 files changed

+270
-48
lines changed

5 files changed

+270
-48
lines changed

internal/tools/index.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ func (s *Server) handleIndexRepository(ctx context.Context, req *mcp.CallToolReq
6161
return errResult(fmt.Sprintf("indexing failed: %v", err)), nil
6262
}
6363

64+
// Add to watcher so auto-sync keeps this project fresh.
65+
s.watcher.Watch(projectName, absPath)
66+
6467
// Update session state if this is the session project
6568
if projectName == s.sessionProject {
6669
s.indexStatus.Store("ready")

internal/tools/projects.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ func (s *Server) handleDeleteProject(_ context.Context, req *mcp.CallToolRequest
8484
if err := s.router.DeleteProject(name); err != nil {
8585
return errResult(fmt.Sprintf("delete failed: %v", err)), nil
8686
}
87+
s.watcher.Unwatch(name)
8788

8889
return jsonResult(map[string]any{
8990
"deleted": name,

internal/tools/tools.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,7 @@ func (s *Server) startAutoIndex() {
282282
return
283283
}
284284
s.indexStatus.Store("ready")
285+
s.watcher.Watch(s.sessionProject, s.sessionRoot)
285286
slog.Info("autoindex.done", "project", s.sessionProject)
286287
}()
287288
}
@@ -302,6 +303,10 @@ func (s *Server) resolveStore(project string) (*store.Store, error) {
302303
if !s.router.HasProject(project) {
303304
return nil, fmt.Errorf("project %q not found; use list_projects to see available projects", project)
304305
}
306+
// Touch watcher so cross-project queries keep that project fresh.
307+
if project != s.sessionProject {
308+
s.watcher.TouchProject(project)
309+
}
305310
return s.router.ForProject(project)
306311
}
307312

@@ -907,6 +912,10 @@ func (s *Server) findNodeAcrossProjects(name string, projectFilter ...string) (*
907912
if !s.router.HasProject(filter) {
908913
return nil, "", fmt.Errorf("project %q not found; use list_projects to see available projects", filter)
909914
}
915+
// Touch watcher so cross-project queries keep that project fresh.
916+
if filter != s.sessionProject {
917+
s.watcher.TouchProject(filter)
918+
}
910919

911920
st, err := s.router.ForProject(filter)
912921
if err != nil {

internal/watcher/watcher.go

Lines changed: 81 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"os/exec"
1010
"path/filepath"
1111
"strings"
12+
"sync"
1213
"sync/atomic"
1314
"time"
1415

@@ -45,7 +46,6 @@ const (
4546
baseInterval = 5 * time.Second
4647
maxInterval = 60 * time.Second
4748
fullSnapshotInterval = 5 // polls between forced full snapshots
48-
projectsCacheTTL = 60 * time.Second
4949
)
5050

5151
type fileSnapshot struct {
@@ -94,40 +94,81 @@ func (ps *projectState) close() {
9494
// IndexFunc is the callback signature for triggering a re-index.
9595
type IndexFunc func(ctx context.Context, projectName, rootPath string) error
9696

97+
// watchEntry tracks a project in the explicit watch list.
98+
type watchEntry struct {
99+
rootPath string
100+
touchedAt time.Time
101+
}
102+
97103
// Watcher polls indexed projects for file changes and triggers re-indexing.
98104
// Change detection uses a 3-tier strategy per project:
99105
//
100106
// 1. Git — git status + HEAD tracking (for git repos)
101107
// 2. FSNotify — event-driven via OS file notifications (for non-git dirs)
102108
// 3. Dir-mtime — directory mtime polling (fallback if fsnotify setup fails)
103109
type Watcher struct {
104-
router *store.StoreRouter
105-
indexFn IndexFunc
106-
projects map[string]*projectState
107-
ctx context.Context
108-
cachedProjects []*store.ProjectInfo
109-
projectsCacheTime time.Time
110+
router *store.StoreRouter
111+
indexFn IndexFunc
112+
projects map[string]*projectState
113+
ctx context.Context
114+
115+
// Explicit watch list — only watched projects get polled.
116+
mu sync.Mutex
117+
watchList map[string]watchEntry
110118

111119
// testStrategy overrides auto-detection when non-zero (for tests).
112120
testStrategy watchStrategy
113121
}
114122

115123
// New creates a Watcher. indexFn is called when file changes are detected.
116124
func New(r *store.StoreRouter, indexFn IndexFunc) *Watcher {
117-
w := &Watcher{
118-
router: r,
119-
indexFn: indexFn,
120-
projects: make(map[string]*projectState),
121-
ctx: context.Background(),
122-
}
123-
// Wire invalidation: when a project is deleted, clear the cache immediately.
124-
r.OnDelete(func(_ string) { w.InvalidateProjectsCache() })
125-
return w
125+
return &Watcher{
126+
router: r,
127+
indexFn: indexFn,
128+
projects: make(map[string]*projectState),
129+
watchList: make(map[string]watchEntry),
130+
ctx: context.Background(),
131+
}
132+
}
133+
134+
// Watch adds a project to the watch list. Called after successful index.
135+
func (w *Watcher) Watch(name, rootPath string) {
136+
w.mu.Lock()
137+
defer w.mu.Unlock()
138+
w.watchList[name] = watchEntry{rootPath: rootPath, touchedAt: time.Now()}
139+
slog.Debug("watcher.watch", "project", name, "path", rootPath)
126140
}
127141

128-
// InvalidateProjectsCache forces the next pollAll to re-query ListProjects.
129-
func (w *Watcher) InvalidateProjectsCache() {
130-
w.projectsCacheTime = time.Time{}
142+
// Unwatch removes a project from the watch list. Called on delete.
143+
func (w *Watcher) Unwatch(name string) {
144+
w.mu.Lock()
145+
defer w.mu.Unlock()
146+
delete(w.watchList, name)
147+
slog.Debug("watcher.unwatch", "project", name)
148+
}
149+
150+
// TouchProject refreshes a project's timestamp in the watch list.
151+
// If the project isn't watched yet, adds it (looks up rootPath from DB).
152+
func (w *Watcher) TouchProject(name string) {
153+
w.mu.Lock()
154+
defer w.mu.Unlock()
155+
if e, ok := w.watchList[name]; ok {
156+
e.touchedAt = time.Now()
157+
w.watchList[name] = e
158+
return
159+
}
160+
// Not yet watched — look up rootPath from DB.
161+
st, release, err := w.router.AcquireStore(name)
162+
if err != nil {
163+
return
164+
}
165+
proj, projErr := st.GetProject(name)
166+
release()
167+
if projErr != nil || proj == nil || proj.RootPath == "" {
168+
return
169+
}
170+
w.watchList[name] = watchEntry{rootPath: proj.RootPath, touchedAt: time.Now()}
171+
slog.Debug("watcher.touch_add", "project", name, "path", proj.RootPath)
131172
}
132173

133174
// Run blocks until ctx is cancelled. Ticks at baseInterval, polling each
@@ -155,55 +196,51 @@ func (w *Watcher) closeAll() {
155196
}
156197
}
157198

158-
// pollAll lists all indexed projects and polls each that is due.
159-
// Prunes watcher state for projects that no longer exist.
199+
// pollAll iterates the explicit watch list and polls each project that is due.
200+
// Prunes watcher state for unwatched projects.
160201
func (w *Watcher) pollAll() {
161-
// Cache ListProjects to avoid repeated ReadDir+SQLite queries.
162-
if time.Since(w.projectsCacheTime) > projectsCacheTTL {
163-
infos, err := w.router.ListProjects()
164-
if err != nil {
165-
slog.Warn("watcher.list_projects", "err", err)
166-
return
167-
}
168-
w.cachedProjects = infos
169-
w.projectsCacheTime = time.Now()
202+
w.mu.Lock()
203+
// Copy watch list under lock to avoid holding lock during poll.
204+
entries := make(map[string]watchEntry, len(w.watchList))
205+
for k, v := range w.watchList {
206+
entries[k] = v
170207
}
171-
projectInfos := w.cachedProjects
208+
w.mu.Unlock()
172209

173-
// Prune stale entries.
174-
activeNames := make(map[string]struct{}, len(projectInfos))
175-
for _, info := range projectInfos {
176-
activeNames[info.Name] = struct{}{}
177-
}
210+
// Prune projectState for unwatched projects.
178211
for name, state := range w.projects {
179-
if _, ok := activeNames[name]; !ok {
212+
if _, ok := entries[name]; !ok {
180213
slog.Info("watcher.prune", "project", name)
181214
state.close()
182215
delete(w.projects, name)
183216
}
184217
}
185218

186219
now := time.Now()
187-
for _, info := range projectInfos {
188-
state, exists := w.projects[info.Name]
220+
for name, entry := range entries {
221+
state, exists := w.projects[name]
189222
if exists && now.Before(state.nextPoll) {
190-
continue // not due yet
223+
continue
191224
}
192225

193-
// AcquireStore increments refs so the evictor can't close mid-query.
194-
st, release, stErr := w.router.AcquireStore(info.Name)
226+
st, release, stErr := w.router.AcquireStore(name)
195227
if stErr != nil {
196228
continue
197229
}
198-
proj, projErr := st.GetProject(info.Name)
230+
proj, projErr := st.GetProject(name)
199231
release()
200232
if projErr != nil || proj == nil {
201233
continue
202234
}
203235

236+
// Use rootPath from watch list (most current).
237+
if entry.rootPath != "" {
238+
proj.RootPath = entry.rootPath
239+
}
240+
204241
if !exists {
205242
state = &projectState{}
206-
w.projects[info.Name] = state
243+
w.projects[name] = state
207244
}
208245

209246
w.pollProject(proj, state)

0 commit comments

Comments
 (0)