Skip to content

Commit 0134485

Browse files
fix: implemented build stop functionality on polling, call cancel endpoint on build cancel (#6815)
Co-authored-by: Gabriel Luiz Freitas Almeida <gabriel@langflow.org>
1 parent e98b25f commit 0134485

File tree

6 files changed

+226
-13
lines changed

6 files changed

+226
-13
lines changed

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

+16-4
Original file line numberDiff line numberDiff line change
@@ -365,7 +365,7 @@ async def build_vertices(
365365
try:
366366
vertex_build_response: VertexBuildResponse = await _build_vertex(vertex_id, graph, event_manager)
367367
except asyncio.CancelledError as exc:
368-
logger.exception(exc)
368+
logger.error(f"Build cancelled: {exc}")
369369
raise
370370

371371
# send built event or error event
@@ -445,6 +445,7 @@ async def cancel_flow_build(
445445
446446
Raises:
447447
ValueError: If the job doesn't exist
448+
asyncio.CancelledError: If the task cancellation failed
448449
"""
449450
# Get the event task and event manager for the job
450451
_, _, event_task = queue_service.get_queue_data(job_id)
@@ -460,13 +461,24 @@ async def cancel_flow_build(
460461
# Store the task reference to check status after cleanup
461462
task_before_cleanup = event_task
462463

463-
# Perform cleanup using the queue service
464-
await queue_service.cleanup_job(job_id)
464+
try:
465+
# Perform cleanup using the queue service
466+
await queue_service.cleanup_job(job_id)
467+
except asyncio.CancelledError:
468+
# Check if the task was actually cancelled
469+
if task_before_cleanup.cancelled():
470+
logger.info(f"Successfully cancelled flow build for job_id {job_id} (CancelledError caught)")
471+
return True
472+
# If the task wasn't cancelled, re-raise the exception
473+
logger.error(f"CancelledError caught but task for job_id {job_id} was not cancelled")
474+
raise
465475

466-
# Verify that the task was actually cancelled
476+
# If no exception was raised, verify that the task was actually cancelled
467477
# The task should be done (cancelled) after cleanup
468478
if task_before_cleanup.cancelled():
469479
logger.info(f"Successfully cancelled flow build for job_id {job_id}")
470480
return True
481+
482+
# If we get here, the task wasn't cancelled properly
471483
logger.error(f"Failed to cancel flow build for job_id {job_id}, task is still running")
472484
return False

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

+5-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import asyncio
34
import time
45
import traceback
56
import uuid
@@ -194,10 +195,12 @@ async def cancel_build(
194195
return CancelFlowResponse(success=True, message="Flow build cancelled successfully")
195196
# Cancellation was attempted but failed
196197
return CancelFlowResponse(success=False, message="Failed to cancel flow build")
197-
198+
except asyncio.CancelledError:
199+
# If CancelledError reaches here, it means the task was not successfully cancelled
200+
logger.error(f"Failed to cancel flow build for job_id {job_id} (CancelledError caught)")
201+
return CancelFlowResponse(success=False, message="Failed to cancel flow build")
198202
except ValueError as exc:
199203
# Job not found
200-
201204
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
202205
except Exception as exc:
203206
# Any other unexpected error

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

+2
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,8 @@ def on_connection(self, dbapi_connection, _connection_record) -> None:
175175
cursor.execute(pragma)
176176
except OperationalError:
177177
logger.exception(f"Failed to set PRAGMA {pragma}")
178+
except GeneratorExit:
179+
logger.error(f"Failed to set PRAGMA {pragma}")
178180
finally:
179181
cursor.close()
180182

src/backend/tests/unit/test_chat_endpoint.py

+164
Original file line numberDiff line numberDiff line change
@@ -193,3 +193,167 @@ async def aiter_lines(self):
193193

194194
# Use the same consume_and_assert_stream function to verify the events
195195
await consume_and_assert_stream(polling_response, job_id)
196+
197+
198+
@pytest.mark.benchmark
199+
async def test_cancel_build_unexpected_error(client, json_memory_chatbot_no_llm, logged_in_headers, monkeypatch):
200+
"""Test handling of unexpected exceptions during flow build cancellation."""
201+
# First create the flow
202+
flow_id = await create_flow(client, json_memory_chatbot_no_llm, logged_in_headers)
203+
204+
# Start the build and get job_id
205+
build_response = await build_flow(client, flow_id, logged_in_headers)
206+
job_id = build_response["job_id"]
207+
assert job_id is not None
208+
209+
# Mock the cancel_flow_build function to raise an unexpected exception
210+
import langflow.api.v1.chat
211+
212+
original_cancel_flow_build = langflow.api.v1.chat.cancel_flow_build
213+
214+
async def mock_cancel_flow_build_with_error(*_args, **_kwargs):
215+
msg = "Unexpected error during cancellation"
216+
raise RuntimeError(msg)
217+
218+
monkeypatch.setattr(langflow.api.v1.chat, "cancel_flow_build", mock_cancel_flow_build_with_error)
219+
220+
try:
221+
# Try to cancel the build - should return 500 Internal Server Error
222+
cancel_response = await client.post(f"api/v1/build/{job_id}/cancel", headers=logged_in_headers)
223+
assert cancel_response.status_code == codes.INTERNAL_SERVER_ERROR
224+
225+
# Verify the error message
226+
response_data = cancel_response.json()
227+
assert "detail" in response_data
228+
assert "Unexpected error during cancellation" in response_data["detail"]
229+
finally:
230+
# Restore the original function to avoid affecting other tests
231+
monkeypatch.setattr(langflow.api.v1.chat, "cancel_flow_build", original_cancel_flow_build)
232+
233+
234+
@pytest.mark.benchmark
235+
async def test_cancel_build_success(client, json_memory_chatbot_no_llm, logged_in_headers, monkeypatch):
236+
"""Test successful cancellation of a flow build."""
237+
# First create the flow
238+
flow_id = await create_flow(client, json_memory_chatbot_no_llm, logged_in_headers)
239+
240+
# Start the build and get job_id
241+
build_response = await build_flow(client, flow_id, logged_in_headers)
242+
job_id = build_response["job_id"]
243+
assert job_id is not None
244+
245+
# Mock the cancel_flow_build function to simulate a successful cancellation
246+
import langflow.api.v1.chat
247+
248+
original_cancel_flow_build = langflow.api.v1.chat.cancel_flow_build
249+
250+
async def mock_successful_cancel_flow_build(*_args, **_kwargs):
251+
return True # Return True to indicate successful cancellation
252+
253+
monkeypatch.setattr(langflow.api.v1.chat, "cancel_flow_build", mock_successful_cancel_flow_build)
254+
255+
try:
256+
# Try to cancel the build (should return success)
257+
cancel_response = await client.post(f"api/v1/build/{job_id}/cancel", headers=logged_in_headers)
258+
assert cancel_response.status_code == codes.OK
259+
260+
# Verify the response structure indicates success
261+
response_data = cancel_response.json()
262+
assert "success" in response_data
263+
assert "message" in response_data
264+
assert response_data["success"] is True
265+
assert "cancelled successfully" in response_data["message"].lower()
266+
finally:
267+
# Restore the original function to avoid affecting other tests
268+
monkeypatch.setattr(langflow.api.v1.chat, "cancel_flow_build", original_cancel_flow_build)
269+
270+
271+
@pytest.mark.benchmark
272+
async def test_cancel_nonexistent_build(client, logged_in_headers):
273+
"""Test cancelling a non-existent flow build."""
274+
# Generate a random job_id that doesn't exist
275+
invalid_job_id = str(uuid.uuid4())
276+
277+
# Try to cancel a non-existent build
278+
response = await client.post(f"api/v1/build/{invalid_job_id}/cancel", headers=logged_in_headers)
279+
assert response.status_code == codes.NOT_FOUND
280+
assert "No queue found for job_id" in response.json()["detail"]
281+
282+
283+
@pytest.mark.benchmark
284+
async def test_cancel_build_failure(client, json_memory_chatbot_no_llm, logged_in_headers, monkeypatch):
285+
"""Test handling of cancellation failure."""
286+
# First create the flow
287+
flow_id = await create_flow(client, json_memory_chatbot_no_llm, logged_in_headers)
288+
289+
# Start the build and get job_id
290+
build_response = await build_flow(client, flow_id, logged_in_headers)
291+
job_id = build_response["job_id"]
292+
assert job_id is not None
293+
294+
# Mock the cancel_flow_build function to simulate a failure
295+
# The import path in monkeypatch should match exactly how it's imported in the application
296+
import langflow.api.v1.chat
297+
298+
original_cancel_flow_build = langflow.api.v1.chat.cancel_flow_build
299+
300+
async def mock_cancel_flow_build(*_args, **_kwargs):
301+
return False # Return False to indicate cancellation failure
302+
303+
monkeypatch.setattr(langflow.api.v1.chat, "cancel_flow_build", mock_cancel_flow_build)
304+
305+
try:
306+
# Try to cancel the build (should return failure but success=False)
307+
cancel_response = await client.post(f"api/v1/build/{job_id}/cancel", headers=logged_in_headers)
308+
assert cancel_response.status_code == codes.OK
309+
310+
# Verify the response structure indicates failure
311+
response_data = cancel_response.json()
312+
assert "success" in response_data
313+
assert "message" in response_data
314+
assert response_data["success"] is False
315+
assert "Failed to cancel" in response_data["message"]
316+
finally:
317+
# Restore the original function to avoid affecting other tests
318+
monkeypatch.setattr(langflow.api.v1.chat, "cancel_flow_build", original_cancel_flow_build)
319+
320+
321+
@pytest.mark.benchmark
322+
async def test_cancel_build_with_cancelled_error(client, json_memory_chatbot_no_llm, logged_in_headers, monkeypatch):
323+
"""Test handling of CancelledError during cancellation (should be treated as failure)."""
324+
# First create the flow
325+
flow_id = await create_flow(client, json_memory_chatbot_no_llm, logged_in_headers)
326+
327+
# Start the build and get job_id
328+
build_response = await build_flow(client, flow_id, logged_in_headers)
329+
job_id = build_response["job_id"]
330+
assert job_id is not None
331+
332+
# Mock the cancel_flow_build function to raise CancelledError
333+
import asyncio
334+
335+
import langflow.api.v1.chat
336+
337+
original_cancel_flow_build = langflow.api.v1.chat.cancel_flow_build
338+
339+
async def mock_cancel_flow_build_with_cancelled_error(*_args, **_kwargs):
340+
msg = "Task cancellation failed"
341+
raise asyncio.CancelledError(msg)
342+
343+
monkeypatch.setattr(langflow.api.v1.chat, "cancel_flow_build", mock_cancel_flow_build_with_cancelled_error)
344+
345+
try:
346+
# Try to cancel the build - should return failure when CancelledError is raised
347+
# since our implementation treats CancelledError as a failed cancellation
348+
cancel_response = await client.post(f"api/v1/build/{job_id}/cancel", headers=logged_in_headers)
349+
assert cancel_response.status_code == codes.OK
350+
351+
# Verify the response structure indicates failure
352+
response_data = cancel_response.json()
353+
assert "success" in response_data
354+
assert "message" in response_data
355+
assert response_data["success"] is False
356+
assert "failed to cancel" in response_data["message"].lower()
357+
finally:
358+
# Restore the original function to avoid affecting other tests
359+
monkeypatch.setattr(langflow.api.v1.chat, "cancel_flow_build", original_cancel_flow_build)

src/frontend/src/controllers/API/api.tsx

+5-4
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,7 @@ export type StreamingRequestParams = {
237237
body?: object;
238238
onError?: (statusCode: number) => void;
239239
onNetworkError?: (error: Error) => void;
240+
buildController: AbortController;
240241
};
241242

242243
async function performStreamingRequest({
@@ -246,18 +247,18 @@ async function performStreamingRequest({
246247
body,
247248
onError,
248249
onNetworkError,
250+
buildController,
249251
}: StreamingRequestParams) {
250252
let headers = {
251253
"Content-Type": "application/json",
252254
// this flag is fundamental to ensure server stops tasks when client disconnects
253255
Connection: "close",
254256
};
255-
const controller = new AbortController();
256-
useFlowStore.getState().setBuildController(controller);
257+
257258
const params = {
258259
method: method,
259260
headers: headers,
260-
signal: controller.signal,
261+
signal: buildController.signal,
261262
};
262263
if (body) {
263264
params["body"] = JSON.stringify(body);
@@ -298,7 +299,7 @@ async function performStreamingRequest({
298299
}
299300
const shouldContinue = await onData(data);
300301
if (!shouldContinue) {
301-
controller.abort();
302+
buildController.abort();
302303
return;
303304
}
304305
} else {

src/frontend/src/utils/buildUtils.ts

+34-3
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { MISSED_ERROR_ALERT } from "@/constants/alerts_constants";
12
import {
23
BASE_URL_API,
34
POLLING_INTERVAL,
@@ -93,7 +94,7 @@ export async function updateVerticesOrder(
9394
);
9495
} catch (error: any) {
9596
setErrorData({
96-
title: "Oops! Looks like you missed something",
97+
title: MISSED_ERROR_ALERT,
9798
list: [error.response?.data?.detail ?? "Unknown Error"],
9899
});
99100
useFlowStore.getState().setIsBuilding(false);
@@ -161,6 +162,7 @@ async function pollBuildEvents(
161162
onGetOrderSuccess?: () => void;
162163
onValidateNodes?: (nodes: string[]) => void;
163164
},
165+
abortController: AbortController,
164166
): Promise<void> {
165167
let isDone = false;
166168
while (!isDone) {
@@ -169,6 +171,7 @@ async function pollBuildEvents(
169171
headers: {
170172
"Content-Type": "application/json",
171173
},
174+
signal: abortController.signal, // Add abort signal to fetch
172175
});
173176

174177
if (!response.ok) {
@@ -184,13 +187,17 @@ async function pollBuildEvents(
184187

185188
// Process the event
186189
const event = JSON.parse(data.event);
187-
await onEvent(
190+
const result = await onEvent(
188191
event.event,
189192
event.data,
190193
buildResults,
191194
verticesStartTimeMs,
192195
callbacks,
193196
);
197+
if (!result) {
198+
isDone = true;
199+
abortController.abort();
200+
}
194201

195202
// Check if this was the end event or if we got a null value
196203
if (event.event === "end" || data.event === null) {
@@ -278,6 +285,24 @@ export async function buildFlowVertices({
278285

279286
const { job_id } = await buildResponse.json();
280287

288+
const cancelBuildUrl = `${BASE_URL_API}build/${job_id}/cancel`;
289+
290+
// Get the buildController from flowStore
291+
const buildController = new AbortController();
292+
buildController.signal.addEventListener("abort", () => {
293+
try {
294+
fetch(cancelBuildUrl, {
295+
method: "POST",
296+
headers: {
297+
"Content-Type": "application/json",
298+
},
299+
});
300+
} catch (error) {
301+
console.error("Error canceling build:", error);
302+
}
303+
});
304+
useFlowStore.getState().setBuildController(buildController);
305+
281306
// Then stream the events
282307
const eventsUrl = `${BASE_URL_API}build/${job_id}/events`;
283308
const buildResults: Array<boolean> = [];
@@ -314,6 +339,7 @@ export async function buildFlowVertices({
314339
"Network error. Please check the connection to the server.",
315340
]);
316341
},
342+
buildController,
317343
});
318344
} else {
319345
const callbacks = {
@@ -329,10 +355,15 @@ export async function buildFlowVertices({
329355
buildResults,
330356
verticesStartTimeMs,
331357
callbacks,
358+
buildController,
332359
);
333360
}
334-
} catch (error) {
361+
} catch (error: unknown) {
335362
console.error("Build process error:", error);
363+
if (error instanceof Error && error.name === "AbortError") {
364+
onBuildStopped && onBuildStopped();
365+
return;
366+
}
336367
onBuildError!("Error Building Flow", [
337368
(error as Error).message || "An unexpected error occurred",
338369
]);

0 commit comments

Comments
 (0)