Skip to content

Commit e702963

Browse files
committed
fix(components): stop legacy aliases from importing every bundle at startup (#14259)
`lfx.components.helpers` and `lfx.components.logic` forwarded moved components with `from lfx.components import utilities` (and `flow_controls` / `llm_operations`). None of those three targets is registered in `lfx.components._dynamic_imports`, so the `from ... import` form falls through to `lfx.components.__getattr__`, which brute-force imports every registered bundle module looking for a name it can never find. Startup reaches this path via `langflow.api.v1.knowledge_bases` -> `memory_base.preprocessing` -> `models_and_agents.agent`, so booting Langflow eagerly imported the entire integration tail. Any third-party package with an import-time side effect then owned process startup: in #14227 mem0's `os.makedirs($HOME/.mem0)` aborted boot on a read-only Kubernetes filesystem, because `_discover_components_from_module` only swallowed ImportError and AttributeError. - Forward through a direct `import_module("lfx.components.<target>")`, matching the idiom the same files already use for submodule forwards. - Make discovery best-effort against any exception, logging at debug, so one misbehaving integration can no longer abort the import that triggered the scan. Measured on the real startup path (`import langflow.api.v1.knowledge_bases`): discovery calls 95 -> 0, component submodules imported 99 -> 7, sys.modules 8985 -> 6226. Behaviour of the aliases is unchanged; #14247 remains the direct fix for mem0. Fixes #14227
1 parent 01d0d04 commit e702963

4 files changed

Lines changed: 161 additions & 7 deletions

File tree

src/lfx/src/lfx/components/__init__.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,14 @@
212212

213213

214214
def _discover_components_from_module(module_name):
215-
"""Discover individual components from a specific module on-demand."""
215+
"""Discover individual components from a specific module on-demand.
216+
217+
Importing a component module executes third-party integration code, which is allowed to fail in
218+
ways that have nothing to do with this package (a missing optional dependency, an import-time
219+
write to a read-only ``$HOME``, a network probe). Discovery is best-effort by design, so any
220+
failure here only removes that module's components from the lookup table -- it must never abort
221+
the import that triggered the scan.
222+
"""
216223
if module_name in _discovered_modules or module_name == "Notion":
217224
return
218225

@@ -230,9 +237,13 @@ def _discover_components_from_module(module_name):
230237

231238
_discovered_modules.add(module_name)
232239

233-
except (ImportError, AttributeError):
240+
except Exception as exc: # noqa: BLE001 - see docstring: discovery must not break the caller
234241
# If import fails, mark as discovered to avoid retrying
235242
_discovered_modules.add(module_name)
243+
# Imported lazily so the failure path is the only thing that pulls in the logger.
244+
from lfx.log.logger import logger
245+
246+
logger.debug(f"Skipping component discovery for '{module_name}': {exc!r}")
236247

237248

238249
# Static base __all__ with module names

src/lfx/src/lfx/components/helpers/__init__.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,9 +103,14 @@ def __getattr__(attr_name: str) -> Any:
103103
raise AttributeError(msg)
104104

105105
# CurrentDateComponent, CalculatorComponent, and IDGeneratorComponent were moved to utilities
106-
# Forward them to utilities for backwards compatibility
106+
# Forward them to utilities for backwards compatibility.
107+
# Import the submodule directly instead of `from lfx.components import utilities`: the latter
108+
# routes through lfx.components.__getattr__, which brute-force imports every registered bundle
109+
# module looking for a name that is not in its _dynamic_imports table. See _discover_all note.
107110
if attr_name in ("CurrentDateComponent", "CalculatorComponent", "IDGeneratorComponent"):
108-
from lfx.components import utilities
111+
from importlib import import_module
112+
113+
utilities = import_module("lfx.components.utilities")
109114

110115
result = getattr(utilities, attr_name)
111116
globals()[attr_name] = result

src/lfx/src/lfx/components/logic/__init__.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,11 @@ def __getattr__(attr_name: str) -> Any:
148148
raise AttributeError(msg)
149149

150150
# Most logic components were moved to flow_controls
151-
# Forward them to flow_controls for backwards compatibility
151+
# Forward them to flow_controls for backwards compatibility.
152+
# Import the submodule directly rather than `from lfx.components import flow_controls`: neither
153+
# flow_controls nor llm_operations is registered in lfx.components._dynamic_imports, so the
154+
# `from ... import` form falls into lfx.components.__getattr__ and brute-force imports every
155+
# bundle module before giving up.
152156
if attr_name in (
153157
"ConditionalRouterComponent",
154158
"DataConditionalRouterComponent",
@@ -159,15 +163,19 @@ def __getattr__(attr_name: str) -> Any:
159163
"RunFlowComponent",
160164
"SubFlowComponent",
161165
):
162-
from lfx.components import flow_controls
166+
from importlib import import_module
167+
168+
flow_controls = import_module("lfx.components.flow_controls")
163169

164170
result = getattr(flow_controls, attr_name)
165171
globals()[attr_name] = result
166172
return result
167173

168174
# SmartRouterComponent was moved to llm_operations
169175
if attr_name == "SmartRouterComponent":
170-
from lfx.components import llm_operations
176+
from importlib import import_module
177+
178+
llm_operations = import_module("lfx.components.llm_operations")
171179

172180
result = getattr(llm_operations, attr_name)
173181
globals()[attr_name] = result
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
"""Regression tests for the legacy component alias modules.
2+
3+
``lfx.components.helpers`` and ``lfx.components.logic`` are backwards-compatibility
4+
shims: the components they advertise now live in ``utilities``, ``flow_controls`` and
5+
``llm_operations``. Forwarding used to be written as ``from lfx.components import
6+
utilities``, which looks harmless but is not -- none of those three target modules is
7+
registered in ``lfx.components._dynamic_imports``, so the ``from ... import`` form falls
8+
through to ``lfx.components.__getattr__``, which brute-force imports *every* registered
9+
bundle module hunting for a name it can never find.
10+
11+
That turned a two-module forward into an eager import of the entire integration tail
12+
during app startup (``langflow.api.v1.knowledge_bases`` reaches here via
13+
``memory_base.preprocessing`` -> ``models_and_agents.agent``). Any third-party package
14+
with an import-time side effect then owns process startup -- see #14227, where mem0's
15+
``os.makedirs($HOME/.mem0)`` aborted boot on a read-only Kubernetes filesystem.
16+
17+
Locked here:
18+
1. The aliases still resolve to the same classes (no behavioural change).
19+
2. Resolving them does not trigger the package-wide discovery scan.
20+
3. Discovery itself swallows *any* import failure, not just ``ImportError``.
21+
"""
22+
23+
from __future__ import annotations
24+
25+
import json
26+
import subprocess
27+
import sys
28+
29+
import pytest
30+
31+
# Bundle modules that a legacy alias lookup has no business importing. These sit early in
32+
# the discovery table, so a reintroduced brute-force scan pulls them in first.
33+
CANARY_BUNDLES = (
34+
"lfx.components.mem0",
35+
"lfx.components.openai",
36+
"lfx.components.anthropic",
37+
"lfx.components.chroma",
38+
)
39+
40+
41+
def _run_probe(script: str) -> dict:
42+
"""Execute ``script`` in a clean interpreter and return its JSON stdout.
43+
44+
A subprocess is required: discovery state (``_discovered_modules``,
45+
``_dynamic_imports``, ``sys.modules``) is process-global, so any earlier test that
46+
touched ``lfx.components`` would mask a regression here.
47+
"""
48+
result = subprocess.run( # noqa: S603
49+
[sys.executable, "-c", script], capture_output=True, text=True, check=False
50+
)
51+
assert result.returncode == 0, result.stderr
52+
return json.loads(result.stdout.strip().splitlines()[-1])
53+
54+
55+
class TestLegacyAliasesResolve:
56+
"""The forwards must keep pointing at the modules the components actually live in."""
57+
58+
def test_helpers_forwards_to_utilities(self):
59+
from lfx.components.helpers import CalculatorComponent, CurrentDateComponent, IDGeneratorComponent
60+
61+
assert CalculatorComponent.__module__ == "lfx.components.utilities.calculator_core"
62+
assert CurrentDateComponent.__module__ == "lfx.components.utilities.current_date"
63+
assert IDGeneratorComponent.__module__ == "lfx.components.utilities.id_generator"
64+
65+
def test_logic_forwards_to_flow_controls(self):
66+
from lfx.components.logic import ConditionalRouterComponent, LoopComponent
67+
68+
assert ConditionalRouterComponent.__module__ == "lfx.components.flow_controls.conditional_router"
69+
assert LoopComponent.__module__ == "lfx.components.flow_controls.loop"
70+
71+
def test_logic_forwards_smart_router_to_llm_operations(self):
72+
from lfx.components.logic import SmartRouterComponent
73+
74+
assert SmartRouterComponent.__module__.startswith("lfx.components.llm_operations.")
75+
76+
77+
class TestLegacyAliasesDoNotScanBundles:
78+
"""Resolving a legacy alias must not drag in the integration tail."""
79+
80+
@pytest.mark.parametrize(
81+
("module", "attr"),
82+
[
83+
("lfx.components.helpers", "CalculatorComponent"),
84+
("lfx.components.helpers", "CurrentDateComponent"),
85+
("lfx.components.logic", "ConditionalRouterComponent"),
86+
("lfx.components.logic", "SmartRouterComponent"),
87+
],
88+
)
89+
def test_alias_lookup_skips_package_wide_discovery(self, module: str, attr: str):
90+
probe = _run_probe(
91+
"import importlib, json, sys\n"
92+
"import lfx.components as pkg\n"
93+
f"getattr(importlib.import_module({module!r}), {attr!r})\n"
94+
"print(json.dumps({\n"
95+
' "discovered": sorted(pkg._discovered_modules),\n'
96+
' "submodules": sorted(\n'
97+
' m for m in sys.modules if m.startswith("lfx.components.") and m.count(".") == 2\n'
98+
" ),\n"
99+
"}))\n"
100+
)
101+
102+
# An empty discovery set is the real assertion: a single entry means __getattr__
103+
# started walking the table.
104+
assert probe["discovered"] == [], (
105+
f"resolving {module}.{attr} triggered package-wide discovery of "
106+
f"{len(probe['discovered'])} module(s): {probe['discovered'][:10]}"
107+
)
108+
leaked = sorted(set(CANARY_BUNDLES) & set(probe["submodules"]))
109+
assert not leaked, f"resolving {module}.{attr} imported unrelated bundles: {leaked}"
110+
111+
112+
class TestDiscoveryIsBestEffort:
113+
"""A misbehaving integration must not be able to abort the caller's import."""
114+
115+
@pytest.mark.parametrize("error", [OSError(30, "Read-only file system"), RuntimeError("boom"), ValueError("bad")])
116+
def test_non_import_errors_are_swallowed(self, monkeypatch: pytest.MonkeyPatch, error: Exception):
117+
import lfx.components as pkg
118+
119+
module_name = "_synthetic_failing_module"
120+
121+
def explode(*_args, **_kwargs):
122+
raise error
123+
124+
monkeypatch.setattr(pkg, "import_mod", explode)
125+
monkeypatch.setattr(pkg, "_discovered_modules", set(pkg._discovered_modules))
126+
127+
pkg._discover_components_from_module(module_name)
128+
129+
# Marked discovered so the failure is not retried on every subsequent lookup.
130+
assert module_name in pkg._discovered_modules

0 commit comments

Comments
 (0)