From af2c4ab046c937e6ebbe786064ec693c914b2f54 Mon Sep 17 00:00:00 2001 From: Riz Ashraf Date: Mon, 21 Sep 2026 19:16:55 +0100 Subject: [PATCH] fix(search): prevent memory leaks and duplication in tantivy search index by explicitly deleting prior terms during updates and syncing store deletions --- server/src/handlers_v2/graph.rs | 7 ++++++- server/src/handlers_v2/tasks.rs | 10 ++++++++-- server/src/handlers_v2/workspaces.rs | 2 ++ server/src/search.rs | 28 ++++++++++++++++++++++++++-- 4 files changed, 42 insertions(+), 5 deletions(-) diff --git a/server/src/handlers_v2/graph.rs b/server/src/handlers_v2/graph.rs index 8b86c7c..1be73e1 100644 --- a/server/src/handlers_v2/graph.rs +++ b/server/src/handlers_v2/graph.rs @@ -196,7 +196,7 @@ impl McpTool for DeleteEntitiesHandler { async fn execute(&self, args: Value, state: Arc) -> Result { let req: DeleteEntitiesTool = serde_json::from_value(args).map_err(|e| e.to_string())?; - let to_delete: HashSet<_> = req.entity_names.into_iter().collect(); + let to_delete: std::collections::HashSet<_> = req.entity_names.into_iter().collect(); state.modify_graph(|master| { for name in &to_delete { master.entities.remove(name); @@ -205,6 +205,11 @@ impl McpTool for DeleteEntitiesHandler { .relations .retain(|r| !to_delete.contains(&r.from) && !to_delete.contains(&r.to)); }); + + let idx = state.search_index.read().unwrap_or_else(|e| e.into_inner()).clone(); + for name in to_delete { + let _ = idx.delete_document(&name).await; + } Ok("Entities deleted".to_string()) } } diff --git a/server/src/handlers_v2/tasks.rs b/server/src/handlers_v2/tasks.rs index 96134c5..0e237cd 100644 --- a/server/src/handlers_v2/tasks.rs +++ b/server/src/handlers_v2/tasks.rs @@ -66,6 +66,7 @@ impl McpTool for DeleteTaskHandler { async fn execute(&self, args: Value, state: Arc) -> Result { let req: DeleteTaskTool = serde_json::from_value(args).map_err(|e| e.to_string())?; let mut deleted_count = 0; + let mut actually_deleted = Vec::new(); state.tasks.modify(|tasks| { let initial_len = tasks.len(); // Collect IDs of tasks to delete (this task + all its recursive children) @@ -93,12 +94,17 @@ impl McpTool for DeleteTaskHandler { queue.extend(children.iter().cloned()); } } - - tasks.retain(|t| !to_delete.contains(&t.id)); + + actually_deleted = to_delete.into_iter().collect(); + tasks.retain(|t| !actually_deleted.contains(&t.id)); deleted_count = initial_len - tasks.len(); }); if deleted_count > 0 { + let idx = state.search_index.read().unwrap_or_else(|e| e.into_inner()).clone(); + for id in actually_deleted { + let _ = idx.delete_document(&id).await; + } Ok(vec![ format!("Deleted task and its children ({} total).", deleted_count).to_string(), ][0] diff --git a/server/src/handlers_v2/workspaces.rs b/server/src/handlers_v2/workspaces.rs index 53ca15e..c54053b 100644 --- a/server/src/handlers_v2/workspaces.rs +++ b/server/src/handlers_v2/workspaces.rs @@ -179,6 +179,8 @@ impl McpTool for DeleteSnippetHandler { deleted = snippets.len() < orig; }); if deleted { + let idx = state.search_index.read().unwrap_or_else(|e| e.into_inner()).clone(); + let _ = idx.delete_document(&req.name).await; Ok("Snippet deleted.".to_string()) } else { Ok("Snippet not found.".to_string()) diff --git a/server/src/search.rs b/server/src/search.rs index 0f87f09..e01ad98 100644 --- a/server/src/search.rs +++ b/server/src/search.rs @@ -57,6 +57,8 @@ impl MemoryIndex { pub fn index_entity(&self, e: &Entity) -> tokio::task::JoinHandle> { let writer = Arc::clone(&self.writer); + let id_field = self.id_field; + let id_val = e.name.clone(); let doc = doc!( self.id_field => e.name.as_str(), @@ -68,6 +70,7 @@ impl MemoryIndex { tokio::task::spawn_blocking(move || { let writer = writer.lock().unwrap_or_else(|e| e.into_inner()); + writer.delete_term(tantivy::Term::from_field_text(id_field, &id_val)); writer.add_document(doc)?; Ok(()) }) @@ -75,22 +78,37 @@ impl MemoryIndex { pub fn index_task(&self, t: &Task) -> tokio::task::JoinHandle> { let writer = Arc::clone(&self.writer); + let id_field = self.id_field; + let id_val = t.id.clone(); let doc = doc!( self.id_field => t.id.as_str(), self.title_field => t.title.as_str(), - self.body_field => t.description.as_str(), + self.body_field => format!("{}\n{}", t.description, t.acceptance_criteria.iter().map(|c| c.description.as_str()).collect::>().join("\n")), self.type_field => "task", self.namespace_field => "global" ); tokio::task::spawn_blocking(move || { let writer = writer.lock().unwrap_or_else(|e| e.into_inner()); + writer.delete_term(tantivy::Term::from_field_text(id_field, &id_val)); writer.add_document(doc)?; Ok(()) }) } + pub fn delete_document(&self, id: &str) -> tokio::task::JoinHandle> { + let writer = Arc::clone(&self.writer); + let id_field = self.id_field; + let id_val = id.to_string(); + + tokio::task::spawn_blocking(move || { + let writer = writer.lock().unwrap_or_else(|e| e.into_inner()); + writer.delete_term(tantivy::Term::from_field_text(id_field, &id_val)); + Ok(()) + }) + } + pub async fn commit(&self) -> tantivy::Result<()> { let writer = Arc::clone(&self.writer); tokio::task::spawn_blocking(move || { @@ -162,17 +180,20 @@ impl MemoryIndex { pub fn index_snippet(&self, s: &Snippet) -> tokio::task::JoinHandle> { let writer = Arc::clone(&self.writer); + let id_field = self.id_field; + let id_val = s.name.clone(); let doc = doc!( self.id_field => s.name.as_str(), self.title_field => s.name.as_str(), - self.body_field => format!("{} {}", s.language, s.description), + self.body_field => format!("{} {}\n{}", s.language, s.description, s.code), self.type_field => "snippet", self.namespace_field => "global" ); tokio::task::spawn_blocking(move || { let writer = writer.lock().unwrap_or_else(|e| e.into_inner()); + writer.delete_term(tantivy::Term::from_field_text(id_field, &id_val)); writer.add_document(doc)?; Ok(()) }) @@ -180,6 +201,8 @@ impl MemoryIndex { pub fn index_adr(&self, a: &Adr) -> tokio::task::JoinHandle> { let writer = Arc::clone(&self.writer); + let id_field = self.id_field; + let id_val = a.id.clone(); let doc = doc!( self.id_field => a.id.as_str(), @@ -191,6 +214,7 @@ impl MemoryIndex { tokio::task::spawn_blocking(move || { let writer = writer.lock().unwrap_or_else(|e| e.into_inner()); + writer.delete_term(tantivy::Term::from_field_text(id_field, &id_val)); writer.add_document(doc)?; Ok(()) })