Skip to content

Commit 5234b4b

Browse files
committed
Merge remote-tracking branch 'origin/release-1.11.3' into fix/le-2134-chatinput-files-1113-resolve
# Conflicts: # src/backend/base/langflow/api/build.py # src/backend/tests/unit/api/test_build_source_flow_provenance.py # src/lfx/src/lfx/_assets/component_index.json # src/lfx/src/lfx/graph/graph/base.py
2 parents ceae24d + 63d4b97 commit 5234b4b

46 files changed

Lines changed: 3793 additions & 214 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.

src/backend/base/langflow/api/build.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from lfx.log.logger import logger
1414
from lfx.schema.legacy_render import project_payload_to_v1
1515
from lfx.schema.schema import InputValueRequest
16+
from lfx.utils.file_path_security import LocalFileAccessError
1617
from sqlmodel import select
1718

1819
from langflow.api.disconnect import DisconnectHandlerStreamingResponse
@@ -571,7 +572,7 @@ async def build_graph_and_get_order() -> tuple[list[str], list[str], Graph]:
571572
error_message=str(exc),
572573
)
573574

574-
if "stream or streaming set to True" in str(exc):
575+
if isinstance(exc, LocalFileAccessError) or "stream or streaming set to True" in str(exc):
575576
raise HTTPException(status_code=400, detail=str(exc)) from exc
576577
await logger.aexception("Error checking build status: " + str(exc))
577578
raise HTTPException(status_code=500, detail=str(exc)) from exc
@@ -616,11 +617,17 @@ async def create_graph(fresh_session, flow_id_str: str, flow_name: str | None) -
616617
)
617618
else:
618619
if not flow_name:
619-
result = await fresh_session.exec(select(Flow.name).where(Flow.id == flow_id))
620+
lookup_flow_id = source_flow_id if source_flow_id is not None else flow_id
621+
result = await fresh_session.exec(select(Flow.name).where(Flow.id == lookup_flow_id))
620622
flow_name = result.first()
621623

624+
# Sanitized public data still contains FileInput references under
625+
# the real source-flow namespace. Build under that trusted scope;
626+
# after parameter containment completes, switch the graph to the
627+
# visitor-virtual execution ID below.
628+
graph_build_flow_id = str(source_flow_id) if source_flow_id is not None else flow_id_str
622629
graph = await build_graph_from_data(
623-
flow_id=flow_id_str,
630+
flow_id=graph_build_flow_id,
624631
payload=data.model_dump(),
625632
user_id=str(current_user.id),
626633
flow_name=flow_name,
@@ -630,8 +637,8 @@ async def create_graph(fresh_session, flow_id_str: str, flow_name: str | None) -
630637

631638
if source_flow_id is not None:
632639
# This value comes from the server-resolved public flow, never from
633-
# request data. ChatInput uses it only to validate already-approved
634-
# public attachment references after flow_id becomes visitor-virtual.
640+
# request data. FileInput containment and ChatInput attachment
641+
# validation use it only after flow_id becomes visitor-virtual.
635642
graph.source_flow_id = str(source_flow_id)
636643
graph.flow_id = str(flow_id)
637644
return graph

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

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from lfx.services.cache.utils import CacheMiss
1616
from lfx.utils.flow_validation import (
1717
CustomComponentValidationError,
18+
prepare_flow_build_for_user,
1819
prepare_public_flow_build,
1920
validate_flow_for_current_settings,
2021
validate_public_flow_no_code_execution,
@@ -177,6 +178,12 @@ async def retrieve_vertices_order(
177178
if not data:
178179
graph = await build_graph_from_db(flow_id=flow_id, session=session, chat_service=chat_service)
179180
else:
181+
sanitized_data = await prepare_flow_build_for_user(
182+
data.model_dump(),
183+
is_superuser=current_user.is_superuser,
184+
)
185+
if sanitized_data is not None:
186+
data = FlowDataRequest.model_validate(sanitized_data)
180187
graph = await build_and_cache_graph_from_data(
181188
flow_id=flow_id, graph_data=data.model_dump(), chat_service=chat_service
182189
)
@@ -214,6 +221,8 @@ async def retrieve_vertices_order(
214221
raise HTTPException(status_code=400, detail=str(exc)) from exc
215222
if isinstance(exc, CustomComponentValidationError):
216223
raise HTTPException(status_code=400, detail=str(exc)) from exc
224+
if isinstance(exc, RuntimeError):
225+
raise HTTPException(status_code=503, detail=str(exc)) from exc
217226
await logger.aexception("Error checking build status")
218227
raise HTTPException(status_code=500, detail=str(exc)) from exc
219228

@@ -308,7 +317,13 @@ async def build_flow(
308317

309318
try:
310319
if data:
311-
validate_flow_for_current_settings(data.model_dump())
320+
raw_data = data.model_dump()
321+
sanitized_data = await prepare_flow_build_for_user(
322+
raw_data,
323+
is_superuser=current_user.is_superuser,
324+
)
325+
if sanitized_data is not None:
326+
data = FlowDataRequest.model_validate(sanitized_data)
312327
elif flow and flow.data:
313328
validate_flow_for_current_settings(flow.data)
314329
except CustomComponentValidationError as exc:

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

Lines changed: 46 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,11 @@
88
from uuid import UUID
99

1010
import orjson
11-
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
11+
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
1212
from fastapi.encoders import jsonable_encoder
1313
from fastapi_pagination import Page, Params
1414
from fastapi_pagination.ext.sqlmodel import apaginate
15+
from lfx.log.logger import logger
1516
from lfx.services.cache.utils import CACHE_MISS
1617
from pydantic import ValidationError
1718
from sqlmodel import and_, col, select
@@ -56,6 +57,10 @@
5657
from langflow.services.authorization.fetch import deny_to_404
5758
from langflow.services.authorization.utils import _resolve_authz_domain
5859
from langflow.services.cache.service import ThreadingInMemoryCache
60+
from langflow.services.database.lock_retry import (
61+
is_database_lock_error,
62+
run_with_lock_retry,
63+
)
5964
from langflow.services.database.models.deployment.exceptions import (
6065
araise_if_deployment_guard_error_or_skip,
6166
)
@@ -74,6 +79,7 @@
7479
# and FlowVersionError from the flow_version modules.
7580
from langflow.services.database.models.folder.constants import DEFAULT_FOLDER_NAME
7681
from langflow.services.database.models.folder.model import Folder
82+
from langflow.services.database.models.user.model import UserRead
7783
from langflow.services.deps import get_settings_service, get_storage_service
7884
from langflow.services.storage.service import StorageService
7985
from langflow.utils.compression import compress_response
@@ -103,6 +109,9 @@ def _handle_unique_constraint_error(exc: Exception, *, status_code: int = 400) -
103109
# build router
104110
router = APIRouter(prefix="/flows", tags=["Flows"])
105111

112+
FLOW_UPDATE_FAILED = "Could not update the flow."
113+
FLOW_UPDATE_BUSY = "The database is busy. Please retry the request."
114+
106115

107116
@router.post("/", response_model=FlowRead, status_code=201)
108117
async def create_flow(
@@ -328,6 +337,7 @@ async def update_flow(
328337
storage_service: Annotated[StorageService, Depends(get_storage_service)],
329338
):
330339
"""Update a flow."""
340+
actor = UserRead.model_validate(current_user, from_attributes=True)
331341
try:
332342
# Destination check: if the payload moves the flow into a new
333343
# workspace/folder, the caller must also be authorized to write at the
@@ -339,7 +349,7 @@ async def update_flow(
339349
if target_workspace_id != db_flow.workspace_id or target_folder_id != db_flow.folder_id:
340350
try:
341351
await ensure_flow_permission(
342-
current_user,
352+
actor,
343353
FlowAction.WRITE,
344354
flow_id=flow_id,
345355
flow_user_id=db_flow.user_id,
@@ -357,7 +367,7 @@ async def update_flow(
357367

358368
async def operation() -> FlowRead:
359369
# Re-load inside each attempt so retry after nested rollback never uses an expired ORM instance.
360-
db_flow_for_attempt = await _read_flow(session=session, flow_id=flow_id, user_id=current_user.id)
370+
db_flow_for_attempt = await _read_flow(session=session, flow_id=flow_id, user_id=actor.id)
361371
if not db_flow_for_attempt:
362372
raise HTTPException(status_code=404, detail="Flow not found")
363373
# TOCTOU: a concurrent PATCH could have moved this flow to a
@@ -367,7 +377,7 @@ async def operation() -> FlowRead:
367377
# stale check across a race.
368378
try:
369379
await ensure_flow_permission(
370-
current_user,
380+
actor,
371381
FlowAction.WRITE,
372382
flow_id=flow_id,
373383
flow_user_id=db_flow_for_attempt.user_id,
@@ -386,7 +396,7 @@ async def operation() -> FlowRead:
386396
):
387397
try:
388398
await ensure_flow_permission(
389-
current_user,
399+
actor,
390400
FlowAction.WRITE,
391401
flow_id=flow_id,
392402
flow_user_id=db_flow_for_attempt.user_id,
@@ -399,26 +409,47 @@ async def operation() -> FlowRead:
399409
session=session,
400410
db_flow=db_flow_for_attempt,
401411
flow=flow,
402-
user_id=current_user.id,
412+
user_id=actor.id,
403413
storage_service=storage_service,
404414
)
405415

406-
if folder_id_will_change:
407-
return await retry_flow_operation_on_deployment_guard(
408-
db=session,
409-
user_id=current_user.id,
410-
flow_ids=[flow_id],
411-
operation=operation,
412-
)
413-
return await operation()
416+
async def update_attempt(_attempt: int) -> FlowRead:
417+
if folder_id_will_change:
418+
return await retry_flow_operation_on_deployment_guard(
419+
db=session,
420+
user_id=actor.id,
421+
flow_ids=[flow_id],
422+
operation=operation,
423+
)
424+
return await operation()
425+
426+
return await run_with_lock_retry(
427+
update_attempt,
428+
session=session,
429+
description=f"update_flow {flow_id}",
430+
)
414431
except HTTPException:
415432
raise
416433
except Exception as e:
417434
await araise_if_deployment_guard_error_or_skip(
418435
e,
419436
log_message=f"op=update_flow flow_id={flow_id}",
420437
)
421-
raise _handle_unique_constraint_error(e) from e
438+
if is_database_lock_error(e):
439+
await logger.awarning("op=update_flow flow_id=%s exhausted lock retries", flow_id)
440+
raise HTTPException(
441+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
442+
detail=FLOW_UPDATE_BUSY,
443+
headers={"Retry-After": "1"},
444+
) from e
445+
handled_error = _handle_unique_constraint_error(e)
446+
if handled_error.status_code != status.HTTP_500_INTERNAL_SERVER_ERROR:
447+
raise handled_error from e
448+
await logger.aerror("op=update_flow flow_id=%s failed with %s", flow_id, type(e).__name__)
449+
raise HTTPException(
450+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
451+
detail=FLOW_UPDATE_FAILED,
452+
) from e
422453

423454

424455
@router.put("/{flow_id}", response_model=FlowRead)

src/backend/base/langflow/api/v2/workflow.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -221,8 +221,8 @@ async def authorize_flow_action(
221221
) from err
222222

223223

224-
def _apply_execution_gates(parsed, flow, current_user: UserRead) -> None:
225-
"""The langflow request gates that run before a flow executes."""
224+
def _apply_execution_gates(parsed, flow, current_user: UserRead):
225+
"""Run request gates and return any server-sanitized execution payload."""
226226
_reject_unsupported_sync_fields(parsed)
227227
_reject_sync_only_fields(parsed)
228228
try:
@@ -234,7 +234,7 @@ def _apply_execution_gates(parsed, flow, current_user: UserRead) -> None:
234234
if exc.status_code == status.HTTP_404_NOT_FOUND:
235235
raise _flow_not_found_http_exception(str(parsed.flow_id)) from exc
236236
raise
237-
_validate_flow_data_for_execution(parsed, flow)
237+
return _validate_flow_data_for_execution(parsed, flow, current_user)
238238

239239

240240
async def run_sync_with_mapping(
@@ -246,7 +246,7 @@ async def run_sync_with_mapping(
246246
background_tasks: BackgroundTasks,
247247
) -> WorkflowExecutionResponse:
248248
"""Inline sync run with the langflow timeout/validation error mapping."""
249-
_apply_execution_gates(parsed, flow, current_user)
249+
parsed = _apply_execution_gates(parsed, flow, current_user)
250250
job_id = uuid4()
251251
try:
252252
return await execute_sync_workflow_with_timeout(
@@ -321,7 +321,7 @@ def build_stream_response(
321321
side-channel, and vertex-build persistence all survive. Validation gates run
322322
before the response is constructed so a bad request fails before streaming.
323323
"""
324-
_apply_execution_gates(parsed, flow, current_user)
324+
parsed = _apply_execution_gates(parsed, flow, current_user)
325325
adapter = get_stream_adapter(
326326
stream_protocol,
327327
StreamAdapterContext(
@@ -346,7 +346,7 @@ async def submit_background_with_mapping(
346346
stream_protocol: str,
347347
) -> WorkflowJobResponse:
348348
"""Queue a durable background run with the langflow service error mapping."""
349-
_apply_execution_gates(parsed, flow, current_user)
349+
parsed = _apply_execution_gates(parsed, flow, current_user)
350350
try:
351351
return await execute_workflow_background(
352352
parsed=parsed,

src/backend/base/langflow/api/v2/workflow_validation.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,14 @@
88

99
from __future__ import annotations
1010

11+
from dataclasses import replace
12+
1113
from fastapi import HTTPException, status
12-
from lfx.utils.flow_validation import CustomComponentValidationError, validate_flow_for_current_settings
14+
from lfx.utils.flow_validation import (
15+
CustomComponentValidationError,
16+
prepare_flow_build_for_user_from_cache,
17+
validate_flow_for_current_settings,
18+
)
1319
from lfx.workflow.converters import ParsedWorkflowRun
1420

1521
from langflow.services.authorization.fetch import deny_to_404
@@ -85,17 +91,27 @@ def _enforce_flow_data_override_owner(parsed: ParsedWorkflowRun, flow: FlowRead,
8591
)
8692

8793

88-
def _validate_flow_data_for_execution(parsed: ParsedWorkflowRun, flow: FlowRead) -> None:
89-
"""Apply the same server-side component policy gate used by v1/public runs."""
94+
def _validate_flow_data_for_execution(
95+
parsed: ParsedWorkflowRun,
96+
flow: FlowRead,
97+
current_user: UserRead,
98+
) -> ParsedWorkflowRun:
99+
"""Apply component policies and return sanitized caller-supplied graph data."""
90100
try:
91101
if parsed.data is not None:
92-
validate_flow_for_current_settings(parsed.data)
102+
sanitized_data = prepare_flow_build_for_user_from_cache(
103+
parsed.data,
104+
is_superuser=current_user.is_superuser,
105+
)
106+
if sanitized_data is not None:
107+
return replace(parsed, data=sanitized_data)
93108
elif flow.data:
94109
validate_flow_for_current_settings(flow.data)
95110
except CustomComponentValidationError as exc:
96111
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
97112
except RuntimeError as exc:
98113
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)) from exc
114+
return parsed
99115

100116

101117
def _validate_output_ids(output_ids: list[str] | None, terminal_node_ids: list[str]) -> None:

src/backend/base/langflow/initial_setup/starter_projects/Content Aggregator.json

Lines changed: 8 additions & 8 deletions
Large diffs are not rendered by default.

src/backend/base/langflow/initial_setup/starter_projects/Structured Data Analysis Agent.json

Lines changed: 8 additions & 8 deletions
Large diffs are not rendered by default.

src/backend/base/langflow/services/database/lock_retry.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,11 @@ class of failure — the only valid recovery is to end the transaction and run i
1616

1717
import asyncio
1818
import random
19+
import sqlite3
1920
from typing import TYPE_CHECKING, TypeVar
2021

2122
from lfx.log.logger import logger
22-
from sqlalchemy.exc import DBAPIError, SQLAlchemyError
23+
from sqlalchemy.exc import SQLAlchemyError
2324

2425
if TYPE_CHECKING:
2526
from collections.abc import Awaitable, Callable
@@ -55,7 +56,10 @@ def is_database_lock_error(exc: BaseException | None) -> bool:
5556
error_name = getattr(exc, "sqlite_errorname", None)
5657
if error_name in _SQLITE_LOCK_ERROR_NAMES:
5758
return True
58-
if isinstance(exc, DBAPIError | SQLAlchemyError) or error_name is not None:
59+
# SQLAlchemy's wrapper text contains the SQL statement and bound
60+
# parameters. Inspect only the underlying SQLite operational error so
61+
# user data containing "database is locked" cannot trigger retries.
62+
if isinstance(exc, sqlite3.OperationalError):
5963
message = str(exc).lower()
6064
if any(marker in message for marker in _SQLITE_LOCK_MESSAGES):
6165
return True

0 commit comments

Comments
 (0)