Skip to content

Commit 543927b

Browse files
authored
Merge branch 'release-1.8.1' into cz/fix-mcp-auto-release
2 parents eb799b9 + 1e9be16 commit 543927b

71 files changed

Lines changed: 8426 additions & 6506 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docker/build_and_push.Dockerfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ RUN apt-get update \
8181
&& apt-get install --no-install-recommends -y curl git libpq5 gnupg xz-utils \
8282
&& apt-get clean \
8383
&& rm -rf /var/lib/apt/lists/*
84+
COPY --from=builder /usr/local/bin/uv /usr/local/bin/uv
85+
COPY --from=builder /usr/local/bin/uvx /usr/local/bin/uvx
8486
RUN ARCH=$(dpkg --print-architecture) \
8587
&& if [ "$ARCH" = "amd64" ]; then NODE_ARCH="x64"; \
8688
elif [ "$ARCH" = "arm64" ]; then NODE_ARCH="arm64"; \

docker/build_and_push_backend.Dockerfile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,8 @@ RUN apt-get update \
5555
xz-utils \
5656
&& apt-get clean \
5757
&& rm -rf /var/lib/apt/lists/*
58-
58+
COPY --from=builder /usr/local/bin/uv /usr/local/bin/uv
59+
COPY --from=builder /usr/local/bin/uvx /usr/local/bin/uvx
5960
# Install Node.js (required for npx-based MCP stdio servers)
6061
RUN ARCH=$(dpkg --print-architecture) \
6162
&& if [ "$ARCH" = "amd64" ]; then NODE_ARCH="x64"; \

docker/build_and_push_base.Dockerfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ RUN apt-get update \
8282
&& apt-get install --no-install-recommends -y curl git libpq5 gnupg xz-utils \
8383
&& apt-get clean \
8484
&& rm -rf /var/lib/apt/lists/*
85+
COPY --from=builder /usr/local/bin/uv /usr/local/bin/uv
86+
COPY --from=builder /usr/local/bin/uvx /usr/local/bin/uvx
8587
RUN ARCH=$(dpkg --print-architecture) \
8688
&& if [ "$ARCH" = "amd64" ]; then NODE_ARCH="x64"; \
8789
elif [ "$ARCH" = "arm64" ]; then NODE_ARCH="arm64"; \

docker/build_and_push_ep.Dockerfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ RUN apt-get update \
7777
&& apt-get install --no-install-recommends -y curl git libpq5 gnupg xz-utils \
7878
&& apt-get clean \
7979
&& rm -rf /var/lib/apt/lists/*
80+
COPY --from=builder /usr/local/bin/uv /usr/local/bin/uv
81+
COPY --from=builder /usr/local/bin/uvx /usr/local/bin/uvx
8082
RUN ARCH=$(dpkg --print-architecture) \
8183
&& if [ "$ARCH" = "amd64" ]; then NODE_ARCH="x64"; \
8284
elif [ "$ARCH" = "arm64" ]; then NODE_ARCH="arm64"; \

docker/build_and_push_with_extras.Dockerfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@ RUN apt-get update \
7878
&& apt-get install --no-install-recommends -y curl git libpq5 gnupg xz-utils \
7979
&& apt-get clean \
8080
&& rm -rf /var/lib/apt/lists/*
81+
COPY --from=builder /usr/local/bin/uv /usr/local/bin/uv
82+
COPY --from=builder /usr/local/bin/uvx /usr/local/bin/uvx
8183
RUN ARCH=$(dpkg --print-architecture) \
8284
&& if [ "$ARCH" = "amd64" ]; then NODE_ARCH="x64"; \
8385
elif [ "$ARCH" = "arm64" ]; then NODE_ARCH="arm64"; \

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "langflow"
3-
version = "1.8.0"
3+
version = "1.8.1"
44
description = "A Python package with a built-in web application"
55
requires-python = ">=3.10,<3.14"
66
license = "MIT"
@@ -17,7 +17,7 @@ maintainers = [
1717
]
1818
# Define your main dependencies here
1919
dependencies = [
20-
"langflow-base[complete]~=0.8.0",
20+
"langflow-base[complete]~=0.8.1",
2121
]
2222

2323

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,17 @@ def has_api_terms(word: str):
6868
return "api" in word and ("key" in word or ("token" in word and "tokens" not in word))
6969

7070

71+
def _get_provider_from_template(template: dict) -> str | None:
72+
"""Return provider name from template's model field, if any."""
73+
model_field = template.get("model")
74+
if not isinstance(model_field, dict):
75+
return None
76+
raw = model_field.get("value")
77+
if isinstance(raw, list) and len(raw) > 0 and isinstance(raw[0], dict):
78+
return raw[0].get("provider")
79+
return None
80+
81+
7182
def remove_api_keys(flow: dict):
7283
"""Remove api keys from flow data."""
7384
for node in flow.get("data", {}).get("nodes", []):

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

0 commit comments

Comments
 (0)