Skip to content

Commit e2aca0a

Browse files
fix(review): address critical issues found in PR #12783 code review
preload.py — fork-safety bug: if _engine.dispose() raised, cache_service. teardown() was skipped entirely, leaving the Redis/cache socket open and inherited by all forked workers. Use separate try/finally so both teardowns always run; re-raise the first error at the end. server.py — _langflow_post_fork had two completely silent except blocks (pass with no logging). Replace with server.log.warning(..., exc_info=True) so fork-safety reset failures are visible in production logs. alembic/env.py — added _db_models.import_all() before target_metadata is read. The models package uses lazy __getattr__; out-of-band `alembic upgrade head` (CLI, docker entrypoint) bypassed _run_migrations, saw an empty SQLModel.metadata, and would generate spurious DROP TABLE operations. models/__init__.py — fixed import_all() docstring: "avoid loading pandas" was wrong (no model submodule imports pandas). Updated to describe the actual reason (cold-start import cost) and document alembic/env.py as a call site. starter_project_hash.py — docstring said main.py owns the hash gate; the actual call site is preload.py. components.py — three fixes: 1. asyncio.Lock() comment claimed it raises RuntimeError on Python 3.13+. This is factually wrong (Lock() has not required a running loop since 3.10). Replaced with the accurate reason: post_fork reset contract. Also removed an orphaned sentence that contradicted the preceding text. 2. Cache-path resolution failure was logged at adebug/debug in two places (invisible at default log levels, silently defeats the cache on every cold start). Both raised to warning.
1 parent 27a304c commit e2aca0a

6 files changed

Lines changed: 72 additions & 36 deletions

File tree

src/backend/base/langflow/alembic/env.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,24 @@
1-
# noqa: INP001
21
import asyncio
32
import hashlib
43
import os
54
from logging.config import fileConfig
65
from typing import Any
76

8-
9-
from alembic import context
7+
from lfx.log.logger import logger
108
from sqlalchemy import pool, text
119
from sqlalchemy.event import listen
1210
from sqlalchemy.ext.asyncio import async_engine_from_config
1311

14-
from lfx.log.logger import logger
15-
12+
from alembic import context
13+
from langflow.services.database import models as _db_models
1614
from langflow.services.database.service import SQLModel
1715

16+
# Eagerly import all SQLModel subclasses so they register in SQLModel.metadata
17+
# before Alembic reads target_metadata. The models package uses a lazy
18+
# __getattr__ to avoid cold-start import cost, so without this call any
19+
# out-of-band `alembic upgrade head` (CLI, docker entrypoint, etc.) would
20+
# see an empty/partial metadata and generate spurious DROP TABLE operations.
21+
_db_models.import_all()
1822

1923
# this is the Alembic Config object, which provides
2024
# access to the values within the .ini file in use.

src/backend/base/langflow/initial_setup/starter_project_hash.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
33
Computes a content hash over the starter-project JSON files plus the installed
44
``lfx`` package version, and persists it as plaintext under
5-
``${LANGFLOW_CONFIG_DIR}/starter_projects.hash``. ``main.py`` uses the hash to
6-
short-circuit the full starter-project re-sync on restarts where nothing
5+
``${LANGFLOW_CONFIG_DIR}/starter_projects.hash``. ``preload.py`` uses the hash
6+
to short-circuit the full starter-project re-sync on restarts where nothing
77
changed.
88
99
Failure modes: missing, unreadable, or corrupt hash files all fall

src/backend/base/langflow/preload.py

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -327,21 +327,29 @@ async def _run_agentic_mcp() -> None:
327327
# usable: on first access in a worker it opens a fresh pool for that
328328
# process.
329329
#
330-
# Guard: if initialize_services raised before registering the DB service
331-
# we skip disposal (nothing to close). We check the service registry
332-
# directly so that a real dispose() failure propagates instead of being
333-
# swallowed — a failed dispose() is a fork-safety hazard.
330+
# CRITICAL: both DB engine disposal and cache service teardown are
331+
# fork-safety-critical — both must run even if one raises. Use
332+
# separate try/finally so a dispose() failure does not skip teardown()
333+
# (and vice versa), then re-raise the first error.
334334
await logger.adebug("[preload] disposing master DB engine before fork")
335335
from langflow.services.manager import get_service_manager
336336
from langflow.services.schema import ServiceType as _ServiceType
337337

338-
if _ServiceType.DATABASE_SERVICE in get_service_manager().services:
339-
_db_svc = get_db_service()
340-
_engine = getattr(_db_svc, "engine", None)
341-
if _engine is not None:
342-
await _engine.dispose()
343-
else:
344-
await logger.adebug("[preload] DB engine dispose skipped (service not yet initialized)")
338+
_db_dispose_err: BaseException | None = None
339+
try:
340+
# Guard: if initialize_services raised before registering the DB
341+
# service we skip disposal (nothing to close). We check the
342+
# service registry directly so that a real dispose() failure
343+
# propagates — a failed dispose() is a fork-safety hazard.
344+
if _ServiceType.DATABASE_SERVICE in get_service_manager().services:
345+
_db_svc = get_db_service()
346+
_engine = getattr(_db_svc, "engine", None)
347+
if _engine is not None:
348+
await _engine.dispose()
349+
else:
350+
await logger.adebug("[preload] DB engine dispose skipped (service not yet initialized)")
351+
except BaseException as _exc: # noqa: BLE001
352+
_db_dispose_err = _exc
345353

346354
# Close cache service socket (e.g. Redis) to prevent sharing across fork.
347355
# ExternalAsyncBaseCacheService declares teardown() abstract, so any
@@ -355,6 +363,9 @@ async def _run_agentic_mcp() -> None:
355363
if isinstance(cache_service, ExternalAsyncBaseCacheService):
356364
await cache_service.teardown()
357365

366+
if _db_dispose_err is not None:
367+
raise _db_dispose_err
368+
358369

359370
def preload_master() -> None:
360371
"""Run one-time Langflow initialization in the gunicorn master before workers are forked.

src/backend/base/langflow/server.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -192,15 +192,24 @@ def _langflow_post_fork(server, worker) -> None: # noqa: ARG001
192192
from langflow.services.deps import get_telemetry_service
193193

194194
get_telemetry_service().client = None
195-
except Exception: # noqa: BLE001, S110
195+
except Exception: # noqa: BLE001
196196
# Service not yet initialized (e.g. preload_app=False path). The
197-
# hook must not crash gunicorn.
198-
pass
197+
# hook must not crash gunicorn, but the failure is worth knowing about.
198+
server.log.warning(
199+
"[post_fork] Failed to reset TelemetryService.client; "
200+
"worker telemetry may fail on first use",
201+
exc_info=True,
202+
)
199203

200204
try:
201205
from lfx.interface.components import component_cache
202206

203207
component_cache._lock = None # noqa: SLF001
204-
except Exception: # noqa: BLE001, S110
205-
# Module not importable in this worker for any reason — non-fatal.
206-
pass
208+
except Exception: # noqa: BLE001
209+
# Module not importable in this worker for any reason — non-fatal,
210+
# but log so operators can diagnose deployment issues.
211+
server.log.warning(
212+
"[post_fork] Failed to reset component_cache._lock; "
213+
"worker may inherit stale asyncio.Lock from master",
214+
exc_info=True,
215+
)

src/backend/base/langflow/services/database/models/__init__.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,13 @@ def import_all() -> None:
6666
6767
Call this before running Alembic migrations or any code that iterates
6868
__dict__ to discover SQLModel subclasses (e.g. schema health checks).
69-
Models are lazy by default to avoid loading pandas at package import time.
69+
Models are lazy by default to reduce cold-start import cost (each submodule
70+
pulls in SQLAlchemy validators, pydantic field definitions, etc.).
7071
71-
All call sites have been audited: only database/service.py iterates __dict__
72-
for model discovery, and both call sites (migration and health-check paths)
73-
call import_all() immediately before the iteration.
72+
Call sites: database/service.py (migration + health-check paths) and
73+
alembic/env.py (so out-of-band `alembic upgrade` sees complete metadata).
74+
All call sites must call import_all() before iterating SQLModel.metadata
75+
or __dict__ for model discovery.
7476
"""
7577
for name in __all__:
7678
getattr(__import__(__name__, fromlist=[name]), name)

src/lfx/src/lfx/interface/components.py

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -48,18 +48,22 @@ def __init__(self):
4848
# None means "not yet loaded" (fail-closed); {} means "loaded, no components found".
4949
self.type_to_current_hash: dict[str, set[str]] | None = None
5050
self.all_known_hashes: set[str] | None = None
51-
# Lazily created on first access from inside a running event loop.
52-
# Constructing asyncio.Lock() at import time raises RuntimeError on
53-
# Python 3.13+ because the singleton below runs at module import.
51+
# Lazily created to avoid binding event-loop state at import time and
52+
# to allow the post_fork hook to reset it to None so each worker
53+
# creates a fresh Lock against its own event loop (see server.py
54+
# _langflow_post_fork).
5455
self._lock: asyncio.Lock | None = None
5556

5657
@property
5758
def lock(self) -> asyncio.Lock:
5859
"""Return the asyncio.Lock, creating it lazily on first access.
5960
60-
Must be called from inside a running event loop: asyncio.Lock() requires
61-
a running loop on Python 3.13+, and the singleton is instantiated at
62-
module import time.
61+
The lock is created on first use (not at import time) so that the
62+
post_fork hook in server.py can reset it to None, causing each worker
63+
to construct a fresh Lock bound to its own event loop on the next
64+
access. Creating it eagerly in __init__ would bind it to the master's
65+
event loop, causing "Future attached to a different loop" errors in
66+
workers.
6367
"""
6468
if self._lock is None:
6569
self._lock = asyncio.Lock()
@@ -380,7 +384,10 @@ async def _load_from_index_or_cache(
380384
try:
381385
cache_path = _get_cache_path()
382386
except Exception as e: # noqa: BLE001
383-
await logger.adebug(f"Cache load failed: {e}")
387+
await logger.awarning(
388+
f"Could not determine component cache path ({type(e).__name__}: {e}); "
389+
"falling through to dynamic rebuild"
390+
)
384391
else:
385392
if cache_path.exists():
386393
await logger.adebug(f"Attempting to load from cache: {cache_path}")
@@ -763,7 +770,10 @@ async def get_and_cache_all_types_dict(
763770
try:
764771
cache_path = _get_cache_path()
765772
except Exception as exc: # noqa: BLE001
766-
logger.debug(f"Could not resolve component cache path: {exc}")
773+
logger.warning(
774+
f"Could not resolve component cache path ({type(exc).__name__}: {exc}); "
775+
"falling through to dynamic rebuild"
776+
)
767777
cache_path = None
768778
if cache_path is not None and cache_path.exists():
769779
cached_blob: Any = None

0 commit comments

Comments
 (0)