Skip to content

Commit 742b394

Browse files
committed
Merge branch 'fix-api-key-components' of https://github.com/langflow-ai/langflow into fix-api-key-components
2 parents 6709d70 + 10d0bcd commit 742b394

13 files changed

Lines changed: 1127 additions & 125 deletions

File tree

src/backend/base/langflow/api/utils/kb_helpers.py

Lines changed: 106 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
import contextlib
33
import gc
44
import json
5+
import shutil
6+
import time
57
import uuid
68
from datetime import datetime, timezone
79
from functools import lru_cache
@@ -25,8 +27,10 @@
2527
from langflow.services.deps import get_settings_service
2628
from langflow.services.jobs.service import JobService
2729
from 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

99187
class 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:

src/backend/base/langflow/api/v1/knowledge_bases.py

Lines changed: 13 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
import asyncio
2-
import gc
32
import json
4-
import shutil
53
import uuid
64
from datetime import datetime, timezone
75
from http import HTTPStatus
@@ -78,11 +76,11 @@ async def create_knowledge_base(
7876
try:
7977
client = KBStorageHelper.get_fresh_chroma_client(kb_path)
8078
client.create_collection(name=kb_name)
81-
# Explicitly delete reference to help release handle
82-
client = None
83-
gc.collect()
8479
except (OSError, ValueError, chromadb.errors.ChromaError) as e:
8580
logger.warning("Initial Chroma setup for %s failed: %s", kb_name, e)
81+
finally:
82+
client = None
83+
KBStorageHelper.release_chroma_resources(kb_path)
8684

8785
# Serialize column_config for persistence
8886
column_config_dicts = None
@@ -130,7 +128,7 @@ async def create_knowledge_base(
130128
except Exception as e:
131129
# Clean up if something went wrong
132130
if kb_path.exists():
133-
shutil.rmtree(kb_path)
131+
KBStorageHelper.delete_storage(kb_path, kb_name)
134132
await logger.aerror("Error creating knowledge base: %s", e)
135133
raise HTTPException(status_code=500, detail="Internal error creating knowledge base") from e
136134

@@ -593,8 +591,9 @@ async def get_knowledge_base_chunks(
593591
await logger.aerror("Error getting chunks for '%s': %s", kb_name, e)
594592
raise HTTPException(status_code=500, detail="Error getting chunks.") from e
595593
finally:
594+
client = None
596595
chroma = None
597-
gc.collect()
596+
KBStorageHelper.release_chroma_resources(kb_path)
598597

599598

600599
@router.delete("/{kb_name}", status_code=HTTPStatus.OK)
@@ -603,11 +602,11 @@ async def delete_knowledge_base(kb_name: str, current_user: CurrentActiveUser) -
603602
try:
604603
kb_path = _resolve_kb_path(kb_name, current_user)
605604

606-
# Explicitly teardown KB storage to flush Chroma handles before directory deletion
607-
KBStorageHelper.teardown_storage(kb_path, kb_name)
608-
609-
# Delete the entire knowledge base directory
610-
shutil.rmtree(kb_path)
605+
if not KBStorageHelper.delete_storage(kb_path, kb_name):
606+
raise HTTPException(
607+
status_code=500,
608+
detail=f"Failed to delete knowledge base '{kb_name}'. The database may be in use.",
609+
)
611610

612611
except HTTPException:
613612
raise
@@ -636,12 +635,8 @@ async def delete_knowledge_bases_bulk(request: BulkDeleteRequest, current_user:
636635
continue
637636

638637
try:
639-
# Explicitly teardown KB storage to flush Chroma handles before directory deletion
640-
KBStorageHelper.teardown_storage(kb_path, kb_name)
641-
642-
# Delete the entire knowledge base directory
643-
shutil.rmtree(kb_path)
644-
deleted_count += 1
638+
if KBStorageHelper.delete_storage(kb_path, kb_name):
639+
deleted_count += 1
645640
except (OSError, PermissionError) as e:
646641
await logger.aexception("Error deleting knowledge base '%s': %s", kb_name, e)
647642
# Continue with other deletions even if one fails

src/backend/base/langflow/utils/kb_constants.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,7 @@
33
EXPONENTIAL_BACKOFF_MULTIPLIER = 2
44
MIN_KB_NAME_LENGTH = 3
55
CHUNK_PREVIEW_MULTIPLIER = 3
6+
7+
# KB deletion retry constants
8+
MAX_DELETE_RETRIES = 5
9+
DELETE_BACKOFF_SECONDS = 0.5

0 commit comments

Comments
 (0)