Skip to content

Commit f5a2c1c

Browse files
authored
fix: Enhance vertex runnability logic with loop detection (#6309)
* feat: add is_loop property to Vertex class for detecting looping outputs * feat: improve vertex runnability logic for graph traversal - Update `is_vertex_runnable` to handle loop vertices more robustly - Modify `are_all_predecessors_fulfilled` to better manage cycle dependencies - Change adjacency maps to use sets for more efficient predecessor/successor tracking * refactor: change graph adjacency maps from lists to sets for improved performance - Update graph data structures to use sets instead of lists for predecessor, successor, and parent-child maps - Modify type hints and method signatures to reflect the change from list to set - Improve graph traversal and vertex tracking efficiency by using set operations
1 parent f3ddbcf commit f5a2c1c

File tree

7 files changed

+88
-41
lines changed

7 files changed

+88
-41
lines changed

src/backend/base/langflow/graph/graph/base.py

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -116,10 +116,10 @@ def __init__(
116116

117117
self.top_level_vertices: list[str] = []
118118
self.vertex_map: dict[str, Vertex] = {}
119-
self.predecessor_map: dict[str, list[str]] = defaultdict(list)
120-
self.successor_map: dict[str, list[str]] = defaultdict(list)
119+
self.predecessor_map: dict[str, set[str]] = defaultdict(set)
120+
self.successor_map: dict[str, set[str]] = defaultdict(set)
121121
self.in_degree_map: dict[str, int] = defaultdict(int)
122-
self.parent_child_map: dict[str, list[str]] = defaultdict(list)
122+
self.parent_child_map: dict[str, set[str]] = defaultdict(set)
123123
self._run_queue: deque[str] = deque()
124124
self._first_layer: list[str] = []
125125
self._lock = asyncio.Lock()
@@ -469,10 +469,10 @@ def _add_edge(self, edge: EdgeData) -> None:
469469
self.add_edge(edge)
470470
source_id = edge["data"]["sourceHandle"]["id"]
471471
target_id = edge["data"]["targetHandle"]["id"]
472-
self.predecessor_map[target_id].append(source_id)
473-
self.successor_map[source_id].append(target_id)
472+
self.predecessor_map[target_id].add(source_id)
473+
self.successor_map[source_id].add(target_id)
474474
self.in_degree_map[target_id] += 1
475-
self.parent_child_map[source_id].append(target_id)
475+
self.parent_child_map[source_id].add(target_id)
476476

477477
def add_node(self, node: NodeData) -> None:
478478
self._vertices.append(node)
@@ -1554,7 +1554,7 @@ async def process(
15541554
logger.debug("Graph processing complete")
15551555
return self
15561556

1557-
def find_next_runnable_vertices(self, vertex_successors_ids: list[str]) -> list[str]:
1557+
def find_next_runnable_vertices(self, vertex_successors_ids: set[str]) -> list[str]:
15581558
next_runnable_vertices = set()
15591559
for v_id in sorted(vertex_successors_ids):
15601560
if not self.is_vertex_runnable(v_id):
@@ -1692,7 +1692,7 @@ def get_all_successors(self, vertex: Vertex, *, recursive=True, flat=True, visit
16921692

16931693
def get_successors(self, vertex: Vertex) -> list[Vertex]:
16941694
"""Returns the successors of a vertex."""
1695-
return [self.get_vertex(target_id) for target_id in self.successor_map.get(vertex.id, [])]
1695+
return [self.get_vertex(target_id) for target_id in self.successor_map.get(vertex.id, set())]
16961696

16971697
def get_vertex_neighbors(self, vertex: Vertex) -> dict[Vertex, int]:
16981698
"""Returns the neighbors of a vertex."""
@@ -1952,7 +1952,8 @@ def sort_layer_by_avg_build_time(vertices_ids: list[str]) -> list[str]:
19521952
def is_vertex_runnable(self, vertex_id: str) -> bool:
19531953
"""Returns whether a vertex is runnable."""
19541954
is_active = self.get_vertex(vertex_id).is_active()
1955-
return self.run_manager.is_vertex_runnable(vertex_id, is_active=is_active)
1955+
is_loop = self.get_vertex(vertex_id).is_loop
1956+
return self.run_manager.is_vertex_runnable(vertex_id, is_active=is_active, is_loop=is_loop)
19561957

19571958
def build_run_map(self) -> None:
19581959
"""Builds the run map for the graph.
@@ -1984,7 +1985,8 @@ def find_runnable_predecessors(predecessor: Vertex) -> None:
19841985
return
19851986
visited.add(predecessor_id)
19861987
is_active = self.get_vertex(predecessor_id).is_active()
1987-
if self.run_manager.is_vertex_runnable(predecessor_id, is_active=is_active):
1988+
is_loop = self.get_vertex(predecessor_id).is_loop
1989+
if self.run_manager.is_vertex_runnable(predecessor_id, is_active=is_active, is_loop=is_loop):
19881990
runnable_vertices.append(predecessor_id)
19891991
else:
19901992
for pred_pred_id in self.run_manager.run_predecessors.get(predecessor_id, []):
@@ -2029,13 +2031,13 @@ def build_in_degree(self, edges: list[CycleEdge]) -> dict[str, int]:
20292031
return in_degree
20302032

20312033
@staticmethod
2032-
def build_adjacency_maps(edges: list[CycleEdge]) -> tuple[dict[str, list[str]], dict[str, list[str]]]:
2034+
def build_adjacency_maps(edges: list[CycleEdge]) -> tuple[dict[str, set[str]], dict[str, set[str]]]:
20332035
"""Returns the adjacency maps for the graph."""
2034-
predecessor_map: dict[str, list[str]] = defaultdict(list)
2035-
successor_map: dict[str, list[str]] = defaultdict(list)
2036+
predecessor_map: dict[str, set[str]] = defaultdict(set)
2037+
successor_map: dict[str, set[str]] = defaultdict(set)
20362038
for edge in edges:
2037-
predecessor_map[edge.target_id].append(edge.source_id)
2038-
successor_map[edge.source_id].append(edge.target_id)
2039+
predecessor_map[edge.target_id].add(edge.source_id)
2040+
successor_map[edge.source_id].add(edge.target_id)
20392041
return predecessor_map, successor_map
20402042

20412043
def __to_dict(self) -> dict[str, dict[str, list[str]]]:

src/backend/base/langflow/graph/graph/constants.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING
4+
15
from langflow.graph.schema import CHAT_COMPONENTS
26
from langflow.utils.lazy_load import LazyLoadDictBase
37

8+
if TYPE_CHECKING:
9+
from langflow.graph.vertex.base import Vertex
10+
from langflow.graph.vertex.vertex_types import CustomComponentVertex
11+
412

513
class Finish:
614
def __bool__(self) -> bool:
@@ -22,7 +30,7 @@ def __init__(self) -> None:
2230
self._types = _import_vertex_types
2331

2432
@property
25-
def vertex_type_map(self):
33+
def vertex_type_map(self) -> dict[str, type[Vertex]]:
2634
return self.all_types_dict
2735

2836
def _build_dict(self):
@@ -32,15 +40,15 @@ def _build_dict(self):
3240
"Custom": ["Custom Tool", "Python Function"],
3341
}
3442

35-
def get_type_dict(self):
43+
def get_type_dict(self) -> dict[str, type[Vertex]]:
3644
types = self._types()
3745
return {
3846
"CustomComponent": types.CustomComponentVertex,
3947
"Component": types.ComponentVertex,
4048
**dict.fromkeys(CHAT_COMPONENTS, types.InterfaceVertex),
4149
}
4250

43-
def get_custom_component_vertex_type(self):
51+
def get_custom_component_vertex_type(self) -> type[CustomComponentVertex]:
4452
return self._types().CustomComponentVertex
4553

4654

src/backend/base/langflow/graph/graph/runnable_vertices_manager.py

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,18 +48,41 @@ def update_run_state(self, run_predecessors: dict, vertices_to_run: set) -> None
4848
self.vertices_to_run.update(vertices_to_run)
4949
self.build_run_map(self.run_predecessors, self.vertices_to_run)
5050

51-
def is_vertex_runnable(self, vertex_id: str, *, is_active: bool) -> bool:
52-
"""Determines if a vertex is runnable."""
51+
def is_vertex_runnable(self, vertex_id: str, *, is_active: bool, is_loop: bool = False) -> bool:
52+
"""Determines if a vertex is runnable based on its active state and predecessor fulfillment."""
5353
if not is_active:
5454
return False
5555
if vertex_id in self.vertices_being_run:
5656
return False
5757
if vertex_id not in self.vertices_to_run:
5858
return False
59-
return self.are_all_predecessors_fulfilled(vertex_id) or vertex_id in self.cycle_vertices
6059

61-
def are_all_predecessors_fulfilled(self, vertex_id: str) -> bool:
62-
return not any(self.run_predecessors.get(vertex_id, []))
60+
return self.are_all_predecessors_fulfilled(vertex_id, is_loop=is_loop)
61+
62+
def are_all_predecessors_fulfilled(self, vertex_id: str, *, is_loop: bool) -> bool:
63+
"""Determines if all predecessors for a vertex have been fulfilled.
64+
65+
This method checks if a vertex is ready to run by verifying that either:
66+
1. It has no pending predecessors that need to complete first
67+
2. For vertices in cycles, none of its pending predecessors are also cycle vertices
68+
(which would create a circular dependency)
69+
70+
Args:
71+
vertex_id (str): The ID of the vertex to check
72+
is_loop (bool): Whether the vertex is a loop
73+
Returns:
74+
bool: True if all predecessor conditions are met, False otherwise
75+
"""
76+
# Get pending predecessors, return True if none exist
77+
if not (pending := self.run_predecessors.get(vertex_id, set())):
78+
return True
79+
80+
# For cycle vertices, check if any pending predecessors are also in cycle
81+
# Using set intersection is faster than iteration
82+
if vertex_id in self.cycle_vertices:
83+
return is_loop or not bool(pending.intersection(self.cycle_vertices))
84+
85+
return False
6386

6487
def remove_from_predecessors(self, vertex_id: str) -> None:
6588
"""Removes a vertex from the predecessor list of its successors."""

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

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -461,8 +461,8 @@ def find_cycle_vertices(edges):
461461
def layered_topological_sort(
462462
vertices_ids: set[str],
463463
in_degree_map: dict[str, int],
464-
successor_map: dict[str, list[str]],
465-
predecessor_map: dict[str, list[str]],
464+
successor_map: dict[str, set[str]],
465+
predecessor_map: dict[str, set[str]],
466466
start_id: str | None = None,
467467
cycle_vertices: set[str] | None = None,
468468
is_input_vertex: Callable[[str], bool] | None = None, # noqa: ARG001
@@ -780,8 +780,8 @@ def get_sorted_vertices(
780780
start_component_id: str | None = None,
781781
graph_dict: dict[str, Any] | None = None,
782782
in_degree_map: dict[str, int] | None = None,
783-
successor_map: dict[str, list[str]] | None = None,
784-
predecessor_map: dict[str, list[str]] | None = None,
783+
successor_map: dict[str, set[str]] | None = None,
784+
predecessor_map: dict[str, set[str]] | None = None,
785785
is_input_vertex: Callable[[str], bool] | None = None,
786786
get_vertex_predecessors: Callable[[str], list[str]] | None = None,
787787
get_vertex_successors: Callable[[str], list[str]] | None = None,
@@ -826,18 +826,18 @@ def get_sorted_vertices(
826826
successor_map = {}
827827
for vertex_id in vertices_ids:
828828
if get_vertex_successors is not None:
829-
successor_map[vertex_id] = get_vertex_successors(vertex_id)
829+
successor_map[vertex_id] = set(get_vertex_successors(vertex_id))
830830
else:
831-
successor_map[vertex_id] = []
831+
successor_map[vertex_id] = set()
832832

833833
# Build predecessor_map if not provided
834834
if predecessor_map is None:
835835
predecessor_map = {}
836836
for vertex_id in vertices_ids:
837837
if get_vertex_predecessors is not None:
838-
predecessor_map[vertex_id] = get_vertex_predecessors(vertex_id)
838+
predecessor_map[vertex_id] = set(get_vertex_predecessors(vertex_id))
839839
else:
840-
predecessor_map[vertex_id] = []
840+
predecessor_map[vertex_id] = set()
841841

842842
# If we have a stop component, we need to filter out all vertices
843843
# that are not predecessors of the stop component

src/backend/base/langflow/graph/vertex/base.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ def __init__(
6666
self.is_state = False
6767
self.is_input = any(input_component_name in self.id for input_component_name in INPUT_COMPONENTS)
6868
self.is_output = any(output_component_name in self.id for output_component_name in OUTPUT_COMPONENTS)
69+
self._is_loop = None
6970
self.has_session_id = None
7071
self.custom_component = None
7172
self.has_external_input = False
@@ -78,7 +79,7 @@ def __init__(
7879
self.built_object: Any = UnbuiltObject()
7980
self.built_result: Any = None
8081
self.built = False
81-
self._successors_ids: list[str] | None = None
82+
self._successors_ids: set[str] | None = None
8283
self.artifacts: dict[str, Any] = {}
8384
self.artifacts_raw: dict[str, Any] = {}
8485
self.artifacts_type: dict[str, str] = {}
@@ -109,6 +110,13 @@ def __init__(
109110
output["name"] for output in self.outputs if isinstance(output, dict) and "name" in output
110111
]
111112

113+
@property
114+
def is_loop(self) -> bool:
115+
"""Check if any output allows looping."""
116+
if self._is_loop is None:
117+
self._is_loop = any(output.get("allows_loop", False) for output in self.outputs)
118+
return self._is_loop
119+
112120
def set_input_value(self, name: str, value: Any) -> None:
113121
if self.custom_component is None:
114122
msg = f"Vertex {self.id} does not have a component instance."
@@ -199,8 +207,8 @@ def successors(self) -> list[Vertex]:
199207
return self.graph.get_successors(self)
200208

201209
@property
202-
def successors_ids(self) -> list[str]:
203-
return self.graph.successor_map.get(self.id, [])
210+
def successors_ids(self) -> set[str]:
211+
return self.graph.successor_map.get(self.id, set())
204212

205213
def __getstate__(self):
206214
state = self.__dict__.copy()

src/backend/base/langflow/graph/vertex/vertex_types.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -466,7 +466,7 @@ def __init__(self, data: NodeData, graph):
466466
self.is_state = False
467467

468468
@property
469-
def successors_ids(self) -> list[str]:
469+
def successors_ids(self) -> set[str]:
470470
if self._successors_ids is None:
471471
self.is_state = False
472472
return super().successors_ids

src/backend/tests/unit/graph/graph/test_runnable_vertices_manager.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,9 @@ def test_is_vertex_runnable(data):
9090
manager = RunnableVerticesManager.from_dict(data)
9191
vertex_id = "A"
9292
is_active = True
93+
is_loop = False
9394

94-
result = manager.is_vertex_runnable(vertex_id, is_active=is_active)
95+
result = manager.is_vertex_runnable(vertex_id, is_active=is_active, is_loop=is_loop)
9596

9697
assert result is False
9798

@@ -100,8 +101,9 @@ def test_is_vertex_runnable__wrong_is_active(data):
100101
manager = RunnableVerticesManager.from_dict(data)
101102
vertex_id = "A"
102103
is_active = False
104+
is_loop = False
103105

104-
result = manager.is_vertex_runnable(vertex_id, is_active=is_active)
106+
result = manager.is_vertex_runnable(vertex_id, is_active=is_active, is_loop=is_loop)
105107

106108
assert result is False
107109

@@ -110,8 +112,9 @@ def test_is_vertex_runnable__wrong_vertices_to_run(data):
110112
manager = RunnableVerticesManager.from_dict(data)
111113
vertex_id = "D"
112114
is_active = True
115+
is_loop = False
113116

114-
result = manager.is_vertex_runnable(vertex_id, is_active=is_active)
117+
result = manager.is_vertex_runnable(vertex_id, is_active=is_active, is_loop=is_loop)
115118

116119
assert result is False
117120

@@ -120,26 +123,29 @@ def test_is_vertex_runnable__wrong_run_predecessors(data):
120123
manager = RunnableVerticesManager.from_dict(data)
121124
vertex_id = "C"
122125
is_active = True
126+
is_loop = False
123127

124-
result = manager.is_vertex_runnable(vertex_id, is_active=is_active)
128+
result = manager.is_vertex_runnable(vertex_id, is_active=is_active, is_loop=is_loop)
125129

126130
assert result is False
127131

128132

129133
def test_are_all_predecessors_fulfilled(data):
130134
manager = RunnableVerticesManager.from_dict(data)
131135
vertex_id = "A"
136+
is_loop = False
132137

133-
result = manager.are_all_predecessors_fulfilled(vertex_id)
138+
result = manager.are_all_predecessors_fulfilled(vertex_id, is_loop=is_loop)
134139

135140
assert result is True
136141

137142

138143
def test_are_all_predecessors_fulfilled__wrong(data):
139144
manager = RunnableVerticesManager.from_dict(data)
140145
vertex_id = "D"
146+
is_loop = False
141147

142-
result = manager.are_all_predecessors_fulfilled(vertex_id)
148+
result = manager.are_all_predecessors_fulfilled(vertex_id, is_loop=is_loop)
143149

144150
assert result is False
145151

0 commit comments

Comments
 (0)