22import contextlib
33import gc
44import json
5+ import shutil
6+ import time
57import uuid
68from datetime import datetime , timezone
79from functools import lru_cache
2527from langflow .services .deps import get_settings_service
2628from langflow .services .jobs .service import JobService
2729from langflow .utils .kb_constants import (
30+ DELETE_BACKOFF_SECONDS ,
2831 EXPONENTIAL_BACKOFF_MULTIPLIER ,
2932 INGESTION_BATCH_SIZE ,
33+ MAX_DELETE_RETRIES ,
3034 MAX_RETRY_ATTEMPTS ,
3135)
3236
@@ -81,8 +85,31 @@ def get_fresh_chroma_client(kb_path: Path) -> chromadb.PersistentClient:
8185 )
8286
8387 @staticmethod
84- def teardown_storage (kb_path : Path , kb_name : str ) -> None :
85- """Explicitly flush and invalidate Chroma clients before directory deletion."""
88+ def release_chroma_resources (kb_path : Path ) -> None :
89+ """Release ChromaDB resources by clearing the registry entry and forcing GC."""
90+ path_key = str (kb_path )
91+ try :
92+ if path_key in SharedSystemClient ._identifier_to_system : # noqa: SLF001
93+ del SharedSystemClient ._identifier_to_system [path_key ] # noqa: SLF001
94+ except KeyError :
95+ pass
96+ gc .collect ()
97+
98+ @staticmethod
99+ def delete_storage (kb_path : Path , kb_name : str ) -> bool :
100+ """Teardown ChromaDB connections and delete KB directory with retry logic.
101+
102+ Handles ChromaDB SQLite file locks that can prevent deletion, particularly
103+ on Windows where mandatory file locks block deletion of open files.
104+ Uses retry with exponential backoff and rename-as-fallback strategy.
105+
106+ Returns:
107+ True if deletion succeeded (or path already gone), False otherwise.
108+ """
109+ if not kb_path .exists ():
110+ return True
111+
112+ # Teardown ChromaDB collection to release handles
86113 try :
87114 has_data = any ((kb_path / m ).exists () for m in ["chroma" , "chroma.sqlite3" , "index" ])
88115 if has_data :
@@ -91,9 +118,70 @@ def teardown_storage(kb_path: Path, kb_name: str) -> None:
91118 with contextlib .suppress (Exception ):
92119 chroma .delete_collection ()
93120 chroma = None
94- gc . collect ()
121+ client = None
95122 except (OSError , ValueError , TypeError , chromadb .errors .ChromaError ) as e :
96- logger .debug (f"Storage teardown failed for { kb_path .name } (ignoring): { e } " )
123+ logger .debug ("Collection teardown failed for %s: %s" , kb_path .name , e )
124+
125+ gc .collect ()
126+
127+ for attempt in range (MAX_DELETE_RETRIES ):
128+ try :
129+ if attempt > 0 :
130+ time .sleep (DELETE_BACKOFF_SECONDS * (2 ** attempt ))
131+
132+ _remove_sqlite_lock_files (kb_path )
133+ _truncate_sqlite_files (kb_path )
134+ gc .collect ()
135+
136+ shutil .rmtree (kb_path , ignore_errors = False )
137+
138+ if not kb_path .exists ():
139+ logger .info ("Deleted knowledge base %s on attempt %d" , kb_name , attempt + 1 )
140+ return True
141+
142+ except OSError as e :
143+ if attempt < MAX_DELETE_RETRIES - 1 :
144+ logger .debug ("KB deletion attempt %d failed for %s: %s" , attempt + 1 , kb_name , e )
145+ else :
146+ logger .warning (
147+ "KB deletion failed for %s after %d attempts: %s" ,
148+ kb_name ,
149+ MAX_DELETE_RETRIES ,
150+ e ,
151+ )
152+
153+ # Last resort: rename for deferred cleanup
154+ if kb_path .exists ():
155+ try :
156+ deferred = kb_path .with_name (f".deleted_{ kb_name } _{ int (time .time ())} " )
157+ kb_path .rename (deferred )
158+ except OSError as e :
159+ logger .warning ("Deferred rename failed for %s: %s" , kb_name , e )
160+ else :
161+ logger .info ("Renamed %s for deferred cleanup" , kb_name )
162+ return True
163+
164+ return False
165+
166+
167+ def _remove_sqlite_lock_files (kb_path : Path ) -> None :
168+ """Remove SQLite auxiliary files (WAL, SHM, journal) that hold locks."""
169+ for pattern in ["*.sqlite3-wal" , "*.sqlite3-shm" , "*.sqlite3-journal" ]:
170+ for lock_file in kb_path .glob (pattern ):
171+ try :
172+ lock_file .unlink ()
173+ except OSError as e :
174+ logger .debug ("Could not remove lock file %s: %s" , lock_file .name , e )
175+
176+
177+ def _truncate_sqlite_files (kb_path : Path ) -> None :
178+ """Truncate SQLite database files to release locks."""
179+ for sqlite_file in kb_path .glob ("*.sqlite3" ):
180+ try :
181+ with sqlite_file .open ("r+b" ) as f :
182+ f .truncate (0 )
183+ except OSError as e :
184+ logger .debug ("Could not truncate %s: %s" , sqlite_file .name , e )
97185
98186
99187class KBAnalysisHelper :
@@ -154,11 +242,15 @@ def get_metadata(kb_path: Path, *, fast: bool = False) -> dict:
154242 @staticmethod
155243 def update_text_metrics (kb_path : Path , metadata : dict , chroma : Chroma | None = None ) -> None :
156244 """Update text metrics (chunks, words, characters) for a knowledge base."""
245+ created_locally = chroma is None
246+ client = None
157247 try :
158- if chroma is None :
248+ if created_locally :
159249 client = KBStorageHelper .get_fresh_chroma_client (kb_path )
160250 chroma = Chroma (client = client , collection_name = kb_path .name )
161251
252+ if chroma is None :
253+ return
162254 collection = chroma ._collection # noqa: SLF001
163255 metadata ["chunks" ] = collection .count ()
164256
@@ -190,6 +282,11 @@ def update_text_metrics(kb_path: Path, metadata: dict, chroma: Chroma | None = N
190282 )
191283 except (OSError , ValueError , TypeError , json .JSONDecodeError , chromadb .errors .ChromaError ) as e :
192284 logger .debug (f"Metrics update failed for { kb_path .name } : { e } " )
285+ finally :
286+ if created_locally :
287+ client = None
288+ chroma = None
289+ KBStorageHelper .release_chroma_resources (kb_path )
193290
194291 @staticmethod
195292 def _detect_embedding_provider (kb_path : Path ) -> str :
@@ -417,8 +514,9 @@ async def perform_ingestion(
417514 await KBIngestionHelper .cleanup_chroma_chunks_by_job (task_job_id , kb_path , kb_name )
418515 raise
419516 finally :
517+ client = None
420518 chroma = None
421- gc . collect ( )
519+ KBStorageHelper . release_chroma_resources ( kb_path )
422520
423521 @staticmethod
424522 async def cleanup_chroma_chunks_by_job (
@@ -438,8 +536,9 @@ async def cleanup_chroma_chunks_by_job(
438536 except (OSError , ValueError , TypeError , chromadb .errors .ChromaError ) as cleanup_error :
439537 await logger .aerror (f"Failed to clean up chunks for job { job_id } : { cleanup_error } " )
440538 finally :
539+ client = None
441540 chroma = None
442- gc . collect ( )
541+ KBStorageHelper . release_chroma_resources ( kb_path )
443542
444543 @staticmethod
445544 async def _is_job_cancelled (job_service : JobService , job_id : uuid .UUID ) -> bool :
0 commit comments