Skip to content

Commit 46fee33

Browse files
committed
fix: harden background workflow result handling
1 parent ca3f6af commit 46fee33

9 files changed

Lines changed: 387 additions & 89 deletions

File tree

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

Lines changed: 33 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -657,40 +657,55 @@ async def get_workflow_status(
657657
persisted_request = (job.job_metadata or {}).get("request") or {}
658658
effective_session_id = persisted_request.get("session_id") or flow_id_str
659659

660-
# Default GET-status path: rebuild from the durable ``output`` events
661-
# the runner captured into ``Job.result`` (langflow-protocol). This
662-
# needs no ``vertex_build`` rows, so it works with vertex-build storage
663-
# off (headless), and skips the graph reconstruction the vertex-build
664-
# path does.
660+
# Default GET-status path: rebuild from the protocol-neutral terminal
661+
# captures the runner stored in ``Job.result``. This needs no
662+
# ``vertex_build`` rows, so it works with vertex-build storage off
663+
# (headless) and skips graph reconstruction.
665664
result = job.result if isinstance(job.result, dict) else {}
666665
output_events = result.get("outputs") or []
667-
if output_events:
668-
return workflow_response_from_output_events(
669-
output_events,
670-
flow_id=flow_id_str,
671-
job_id=job_id_str,
672-
session_id=effective_session_id,
673-
)
666+
partial_stored_response: WorkflowExecutionResponse | None = None
667+
if isinstance(output_events, list) and output_events:
668+
try:
669+
return workflow_response_from_output_events(
670+
output_events,
671+
flow_id=flow_id_str,
672+
job_id=job_id_str,
673+
session_id=effective_session_id,
674+
fail_on_rejected=True,
675+
)
676+
except ValueError:
677+
# Preserve every decodable capture in case the legacy
678+
# vertex-build fallback is also unavailable.
679+
partial_stored_response = workflow_response_from_output_events(
680+
output_events,
681+
flow_id=flow_id_str,
682+
job_id=job_id_str,
683+
session_id=effective_session_id,
684+
)
674685

675-
# Fallback: ``Job.result`` carried no outputs (an agui-protocol run
676-
# leaves it empty; the terminal output lives only on /events) or a
677-
# legacy job predates output capture. Reconstruct from the
678-
# ``vertex_build`` rows keyed by job_id when they exist; if none do,
679-
# ValueError degrades to a bare COMPLETED with an empty outputs map.
686+
# Fallback: ``Job.result`` carried no outputs, has an invalid shape,
687+
# contains rejected/version-skewed entries, or predates capture.
688+
# Reconstruct from ``vertex_build`` rows keyed by job_id when present.
680689
try:
681-
return await reconstruct_workflow_response_from_job_id(
690+
reconstructed = await reconstruct_workflow_response_from_job_id(
682691
session=session,
683692
flow=flow,
684693
job_id=job_id_str,
685694
user_id=str(current_user.id),
686695
)
687696
except ValueError:
697+
if partial_stored_response is not None:
698+
return partial_stored_response
688699
return workflow_response_from_output_events(
689700
[],
690701
flow_id=flow_id_str,
691702
job_id=job_id_str,
692703
session_id=effective_session_id,
693704
)
705+
else:
706+
if reconstructed.session_id is None:
707+
reconstructed = reconstructed.model_copy(update={"session_id": effective_session_id})
708+
return reconstructed
694709

695710
if job.status == JobStatus.FAILED:
696711
# Surface the durable error JSON the runner persisted, additively.

src/backend/base/langflow/services/background_execution/runner.py

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,6 @@ async def _suspend(self, job_id: UUID, exc: PauseRequested) -> None:
147147
finalizer, or close the live bus — the run is resumable.
148148
"""
149149
await self._jobs.append_event(job_id, HUMAN_INPUT_REQUIRED_EVENT, exc.payload)
150-
await self._jobs.update_job_status(job_id, JobStatus.SUSPENDED)
151150
metadata: dict[str, Any] = {}
152151
if exc.request_id is not None:
153152
metadata["pending_request_id"] = exc.request_id
@@ -159,8 +158,10 @@ async def _suspend(self, job_id: UUID, exc: PauseRequested) -> None:
159158
# pre-seed its capture list. Deduped so a resume-pause-resume cycle does
160159
# not accumulate duplicates (the pre-seed already folds the prior stash in).
161160
metadata["pre_pause_outputs"] = self._dedup_outputs(exc.output_events)
162-
if metadata:
163-
await self._jobs.update_job_metadata(job_id, metadata)
161+
# Publish SUSPENDED only when the resume guard and pre-pause captures are
162+
# visible in the same committed row. Otherwise a racing resume can claim
163+
# the job between separate status and metadata transactions.
164+
await self._jobs.suspend_job(job_id, metadata)
164165

165166
async def _finalize_terminal_event(self, job_id: UUID) -> None:
166167
"""Backfill the error blob + terminal event for TIMED_OUT / CANCELLED.
@@ -238,12 +239,11 @@ async def _drive(self, *, job_id: UUID, source_kwargs: dict[str, Any]) -> None:
238239
last_durable_seq = 0
239240
errored_payload: dict[str, Any] | None = None
240241
# Terminal outputs the run produced, captured so GET status can return
241-
# the result without forcing a /events re-attach. The langflow adapter
242-
# normalizes each terminal output into a durable ``output`` event whose
243-
# ``data`` is the same ``OutputEvent`` (a ``ComponentOutput`` plus its
244-
# component id) that sync returns in ``outputs[id]``. The agui adapter
245-
# does not emit these, so agui-protocol runs leave this empty and their
246-
# status stays result-less (the result is still on the /events log).
242+
# the result without forcing a /events re-attach. The frame source emits
243+
# a protocol-neutral off-wire capture for both langflow and AG-UI. The
244+
# public langflow ``output`` event is also accepted as a fallback for
245+
# injected/exported FrameSource implementations that do not emit the
246+
# private capture; final deduplication keeps the two paths from doubling.
247247
#
248248
# A resumed run starts a FRESH frame stream and the output loop skips
249249
# re-emitting checkpoint-restored vertices, so any terminal output the
@@ -256,7 +256,7 @@ async def _drive(self, *, job_id: UUID, source_kwargs: dict[str, Any]) -> None:
256256
# Off-wire terminal-output capture (both protocols): record it into
257257
# the in-memory result ONLY. Deliberately no ``append_event`` (never
258258
# in ``job_events``) and no ``publish`` (never on the live bus), so the
259-
# wire is unchanged and ``Job.result`` fills from these captures alone.
259+
# wire is unchanged and both protocols populate ``Job.result``.
260260
payload = self._decode_payload(frame_bytes)
261261
output_data = payload.get("data")
262262
if isinstance(output_data, dict):
@@ -287,13 +287,14 @@ async def _drive(self, *, job_id: UUID, source_kwargs: dict[str, Any]) -> None:
287287
seq = await self._jobs.append_event(job_id, event_type, payload)
288288
last_durable_seq = seq
289289
await self._bus.publish(str(job_id), LiveFrame(seq=seq, data=self._restamp_id(frame_bytes, seq)))
290+
if event_type == "output":
291+
# Fallback for custom frame sources that emit the standard
292+
# adapter event but not WORKFLOW_OUTPUT_CAPTURE_EVENT.
293+
output_data = payload.get("data")
294+
if isinstance(output_data, dict):
295+
output_events.append(output_data)
290296
if event_type == self._adapter.terminal_error_type:
291297
errored_payload = payload
292-
# NOTE: terminal outputs are captured off-wire via the
293-
# WORKFLOW_OUTPUT_CAPTURE_EVENT frame above (protocol-neutral), not
294-
# from the langflow adapter's durable wire ``output`` event — that
295-
# left agui-protocol ``Job.result`` empty. The wire ``output`` still
296-
# flows to streaming clients via ``append_event``/``publish`` here.
297298
else:
298299
await self._bus.publish(
299300
str(job_id),

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,26 @@ async def update_job_metadata(
248248
await session.flush()
249249
return job
250250

251+
async def suspend_job(self, job_id: UUID, metadata: dict) -> Job | None:
252+
"""Atomically merge pause metadata and transition a job to ``SUSPENDED``.
253+
254+
Resume-critical metadata must become visible in the same transaction as
255+
the status transition. Otherwise a status reader can observe
256+
``SUSPENDED`` before ``pending_request_id`` or ``pre_pause_outputs`` is
257+
committed and return an incomplete pause response.
258+
259+
Returns the updated Job, or ``None`` if the row does not exist.
260+
"""
261+
async with session_scope() as session:
262+
job = await session.get(Job, job_id)
263+
if job is None:
264+
return None
265+
job.job_metadata = {**(job.job_metadata or {}), **metadata}
266+
job.status = JobStatus.SUSPENDED
267+
session.add(job)
268+
await session.flush()
269+
return job
270+
251271
async def set_result(self, job_id: UUID, result: dict | None) -> Job | None:
252272
"""Persist the durable terminal result blob for a job.
253273

src/backend/tests/unit/api/v2/test_workflow.py

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from httpx import AsyncClient
2020
from langflow.services.database.models.flow.model import Flow
2121
from langflow.services.database.models.jobs.model import Job, JobType
22-
from lfx.schema.workflow import JobStatus
22+
from lfx.schema.workflow import JobStatus, WorkflowExecutionResponse
2323
from lfx.services.deps import session_scope
2424
from sqlalchemy.exc import OperationalError
2525

@@ -203,7 +203,7 @@ async def test_get_status_completed_reconstruction(
203203
client: AsyncClient,
204204
created_api_key,
205205
):
206-
"""Test GET /workflow returns reconstructed response for a completed job."""
206+
"""Vertex-build fallback keeps the submitted session for a data-only result."""
207207
job_id = uuid4()
208208

209209
flow_id = uuid4()
@@ -213,6 +213,8 @@ async def test_get_status_completed_reconstruction(
213213
mock_job.status = JobStatus.COMPLETED
214214
mock_job.type = JobType.WORKFLOW
215215
mock_job.user_id = None
216+
mock_job.result = None
217+
mock_job.job_metadata = {"request": {"session_id": "submitted-session"}}
216218

217219
with (
218220
patch("langflow.api.v2.workflow.get_job_service") as mock_get_job_service,
@@ -227,16 +229,86 @@ async def test_get_status_completed_reconstruction(
227229
mock_flow.id = flow_id
228230
mock_get_flow.return_value = mock_flow
229231

230-
mock_reconstruct.return_value = {"flow_id": str(flow_id), "status": "completed", "outputs": {}}
232+
mock_reconstruct.return_value = WorkflowExecutionResponse(
233+
flow_id=str(flow_id),
234+
status=JobStatus.COMPLETED,
235+
outputs={},
236+
session_id=None,
237+
)
231238

232239
headers = {"x-api-key": created_api_key.api_key}
233240
response = await client.get(f"api/v2/workflows?job_id={job_id}", headers=headers)
234241

235242
assert response.status_code == 200
236243
result = response.json()
237244
assert result["status"] == "completed"
245+
assert result["session_id"] == "submitted-session"
238246
mock_reconstruct.assert_called_once()
239247

248+
async def test_get_status_malformed_stored_output_falls_back_to_vertex_builds(
249+
self,
250+
client: AsyncClient,
251+
created_api_key,
252+
):
253+
"""A truthy but incomplete Job.result must not suppress valid vertex-build recovery."""
254+
job_id = uuid4()
255+
flow_id = uuid4()
256+
mock_job = MagicMock(
257+
job_id=job_id,
258+
flow_id=flow_id,
259+
status=JobStatus.COMPLETED,
260+
type=JobType.WORKFLOW,
261+
user_id=None,
262+
result={
263+
"outputs": [
264+
{"component_id": 123, "type": "message", "status": "completed", "content": "ignored"},
265+
{"component_id": [], "type": "message", "status": "completed", "content": "ignored"},
266+
]
267+
},
268+
job_metadata={"request": {"session_id": "submitted-session"}},
269+
)
270+
271+
with (
272+
patch("langflow.api.v2.workflow.get_job_service") as mock_get_job_service,
273+
patch("langflow.api.v2.workflow.get_flow_by_id_or_endpoint_name") as mock_get_flow,
274+
patch("langflow.api.v2.workflow.reconstruct_workflow_response_from_job_id") as mock_reconstruct,
275+
):
276+
mock_service = MagicMock()
277+
mock_service.get_job_by_job_id = AsyncMock(return_value=mock_job)
278+
mock_get_job_service.return_value = mock_service
279+
280+
mock_flow = MagicMock(id=flow_id)
281+
mock_get_flow.return_value = mock_flow
282+
mock_reconstruct.return_value = WorkflowExecutionResponse(
283+
flow_id=str(flow_id),
284+
status=JobStatus.COMPLETED,
285+
outputs={
286+
"DataOutput-1": {
287+
"type": "data",
288+
"status": JobStatus.COMPLETED,
289+
"content": {"ok": True},
290+
}
291+
},
292+
)
293+
294+
response = await client.get(
295+
f"api/v2/workflows?job_id={job_id}",
296+
headers={"x-api-key": created_api_key.api_key},
297+
)
298+
299+
assert response.status_code == 200
300+
assert response.json()["outputs"] == {
301+
"DataOutput-1": {
302+
"type": "data",
303+
"status": "completed",
304+
"display_name": None,
305+
"content": {"ok": True},
306+
"metadata": None,
307+
}
308+
}
309+
assert response.json()["session_id"] == "submitted-session"
310+
mock_reconstruct.assert_awaited_once()
311+
240312
async def test_get_status_timed_out(
241313
self,
242314
client: AsyncClient,

src/backend/tests/unit/api/v2/test_workflow_background.py

Lines changed: 38 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ async def test_background_status_returns_output(client, created_api_key, bg_flow
132132
assert body["outputs"], f"completed background status carried no outputs: {body}"
133133

134134

135-
async def test_background_status_from_job_table_with_vertex_builds_off(client, created_api_key, bg_flow):
135+
async def test_background_status_from_job_table_with_vertex_builds_off(client, created_api_key, bg_flow, monkeypatch):
136136
"""With vertex_build storage OFF, GET status must still carry the full output.
137137
138138
Proves the headless-executor path: disable ``vertex_builds_storage_enabled`` so
@@ -150,49 +150,45 @@ async def test_background_status_from_job_table_with_vertex_builds_off(client, c
150150
from lfx.services.deps import get_settings_service
151151

152152
settings = get_settings_service().settings
153-
original = settings.vertex_builds_storage_enabled
154-
settings.vertex_builds_storage_enabled = False
155-
try:
156-
submit = await client.post("api/v2/workflows", json=_body(bg_flow), headers=_headers(created_api_key))
157-
assert submit.status_code == 200, submit.text
158-
job_id = submit.json()["job_id"]
159-
160-
row = None
161-
for _ in range(200):
162-
async with session_scope() as session:
163-
row = await session.get(Job, UUID(job_id))
164-
if row is not None and row.status in (
165-
JobStatus.COMPLETED,
166-
JobStatus.FAILED,
167-
JobStatus.TIMED_OUT,
168-
):
169-
break
170-
await asyncio.sleep(0.1)
171-
assert row is not None, "job row was never created"
172-
assert row.status == JobStatus.COMPLETED, f"job did not complete: {row.status}"
153+
monkeypatch.setattr(settings, "vertex_builds_storage_enabled", False)
154+
submit = await client.post("api/v2/workflows", json=_body(bg_flow), headers=_headers(created_api_key))
155+
assert submit.status_code == 200, submit.text
156+
job_id = submit.json()["job_id"]
173157

174-
# Proof 1: storage OFF => no vertex_build rows persisted for this job_id.
158+
row = None
159+
for _ in range(200):
175160
async with session_scope() as session:
176-
vbs = await get_vertex_builds_by_job_id(session, job_id)
177-
assert not vbs, f"vertex_builds were written despite storage OFF: {len(vbs)} rows"
178-
179-
# Proof 2: the durable Job.result blob carries the captured terminal outputs.
180-
assert isinstance(row.result, dict), f"Job.result is not a dict: {row.result!r}"
181-
assert row.result.get("outputs"), f"Job.result carried no outputs: {row.result}"
182-
183-
# Proof 3: GET status returns the full output, sourced from Job.result (reconstruct
184-
# finds nothing with storage off, so the COMPLETED branch falls back to Job.result).
185-
status = await client.get("api/v2/workflows", params={"job_id": job_id}, headers=_headers(created_api_key))
186-
assert status.status_code == 200, status.text
187-
body = status.json()
188-
assert body["status"] == "completed"
189-
assert body["outputs"], f"GET status carried no outputs with vertex_builds OFF: {body}"
190-
# Proof 4: the Job.result path recovers the session_id from the terminal
191-
# events (parity with the vertex-build path), so a background GET can
192-
# continue the same chat thread even with vertex-build storage off.
193-
assert body.get("session_id"), f"GET status lost session_id with vertex_builds OFF: {body}"
194-
finally:
195-
settings.vertex_builds_storage_enabled = original
161+
row = await session.get(Job, UUID(job_id))
162+
if row is not None and row.status in (
163+
JobStatus.COMPLETED,
164+
JobStatus.FAILED,
165+
JobStatus.TIMED_OUT,
166+
):
167+
break
168+
await asyncio.sleep(0.1)
169+
assert row is not None, "job row was never created"
170+
assert row.status == JobStatus.COMPLETED, f"job did not complete: {row.status}"
171+
172+
# Proof 1: storage OFF => no vertex_build rows persisted for this job_id.
173+
async with session_scope() as session:
174+
vbs = await get_vertex_builds_by_job_id(session, job_id)
175+
assert not vbs, f"vertex_builds were written despite storage OFF: {len(vbs)} rows"
176+
177+
# Proof 2: the durable Job.result blob carries the captured terminal outputs.
178+
assert isinstance(row.result, dict), f"Job.result is not a dict: {row.result!r}"
179+
assert row.result.get("outputs"), f"Job.result carried no outputs: {row.result}"
180+
181+
# Proof 3: GET status returns the full output, sourced from Job.result (reconstruct
182+
# finds nothing with storage off, so the COMPLETED branch falls back to Job.result).
183+
status = await client.get("api/v2/workflows", params={"job_id": job_id}, headers=_headers(created_api_key))
184+
assert status.status_code == 200, status.text
185+
body = status.json()
186+
assert body["status"] == "completed"
187+
assert body["outputs"], f"GET status carried no outputs with vertex_builds OFF: {body}"
188+
# Proof 4: the Job.result path recovers session_id from the persisted submit
189+
# request in job.job_metadata["request"], so a background GET can continue
190+
# the same chat thread even with vertex-build storage off.
191+
assert body.get("session_id"), f"GET status lost session_id with vertex_builds OFF: {body}"
196192

197193

198194
async def test_background_agui_populates_job_result_outputs(client, created_api_key, bg_flow):

0 commit comments

Comments
 (0)