fix: thread-safe node input reads and JSON parse error handling - #292
Conversation
… handling get_node_inputs_for_edges and get_node_inputs read shared dicts (node_outputs, node_execution_contexts, skipped_nodes) without acquiring self.lock, while store_node_output writes under lock and _build_context correctly snapshots under lock. This creates write- under-lock / read-without-lock races during parallel node execution. Fix: snapshot shared state under self.lock before iteration, matching the existing pattern in _build_context. Also wrap non-batch JSON parse in llm_node and agent_node with try-except, raising NodeTraceableExecutionError to preserve trace_id. Batch mode already handles parse errors per-item; this brings non-batch mode to parity.
|
Thanks for the fix @isheng-eqi 🙏 This looks like a valid bug fix and the implementation is well targeted. 🚀 Before merge, please add committed backend tests. Heym / AGENTS.md requires tests for behavior changes, and this PR currently adds none. Requested coverage:
One lock-discipline concern: Otherwise this is a clean fix. Thanks again. 🌸 |
Address reviewer feedback for PR heymrun#292: 1. Lock fix: wrap all skipped_nodes mutations during parallel execution with self.lock, matching the existing snapshot pattern used for reads. - execute_node_parallel: branch routing calls - execute_node: condition/switch skip_branch_targets_preserving_shared_downstream - reset_nodes_for_execution: skipped_nodes.discard - prepare_loop_for_reexecution: skipped_nodes.discard 2. Tests: add 9 tests covering all requested scenarios - LLM/Agent JSON parse errors preserve trace_id via NodeTraceableExecutionError - JSON parse errors raise ValueError when no trace_id - Batch mode per-item parse error handling unchanged - Two parallel upstream nodes feed one downstream node collects both inputs - Loop execution and re-execution works with input snapshot change - Output allowDownstream returns early while workflow completes - Background do-not-wait execution records downstream results
|
Thanks for the thorough review @ckakgun! All feedback addressed: Lock discipline fix: Added
Tests: Added 9 tests covering all 5 requested scenarios in
All 20 tests pass (9 new + 11 existing branching regression). Ruff lint clean. Let me know if anything else is needed! |
|
Thanks for the update 💯 I pulled the latest head (b554625) and reran the checks 🙏 Runtime behavior looks good: 1932 backend unit tests pass, 81 Playwright e2e tests pass, Two things before merge:
Tests pass, so this does not look like an immediate functional break under CPython, but the claim that |
The previous lock fix only covered the inline condition/switch paths in workflow_executor.py (which run in test_mode with pinnedData). Normal execution dispatches through modular handlers in node_execution/nodes/, which also mutate skipped_nodes without self.lock: - condition_node.py: skip_branch_targets_preserving_shared_downstream - switch_node.py: skip_branch_targets_preserving_shared_downstream - loop_node.py: skip_branch_targets_preserving_shared_downstream - disable_node_node.py: skipped_nodes.add() All four handlers now acquire self.lock before mutating skipped_nodes, matching the snapshot read pattern in get_node_inputs/get_node_inputs_for_edges. Also: ruff format test_workflow_executor_thread_safety.py.
|
Good catches, both fixed:
All 20 tests still pass, ruff format and lint clean. Let me know if anything else! |
ckakgun
left a comment
There was a problem hiding this comment.
Thanks, the latest commit addresses both points. Looks merge-safe from my side. 🤗
Summary
Fix two categories of runtime safety issues in the workflow execution engine.
Problem 1: Race condition in node input reads (severity: high)
get_node_inputs_for_edgesandget_node_inputsread shared dicts (node_outputs,node_execution_contexts,skipped_nodes) without acquiringself.lock, whilestore_node_outputwrites these dicts under lock and_build_contextcorrectly snapshots under lock. During parallel node execution, this creates a classic write-under-lock / read-without-lock race:store_node_output(line 2751):with self.lock: self.node_outputs[node_id] = outputget_node_inputs_for_edges(line 2373):source_id in self.node_outputs— no lock_build_context(line 5476):with self.lock: ...snapshot...— correct patternFix: Snapshot
node_outputs,node_execution_contexts, andskipped_nodesunderself.lockbefore iteration, matching the existing_build_contextpattern.Problem 2: Missing JSON parse error handling (severity: medium)
llm_node.py:179andagent_node.py:46callself._parse_json_output()without try-except in non-batch JSON output mode. If the LLM returns empty text or malformed JSON, the exception propagates through the retry loop without preserving the LLM trace context (trace_id). The batch mode atllm_node.py:163-168already handles this correctly per-item.Fix: Wrap
_parse_json_outputin try-except. Whentrace_idis available, raiseNodeTraceableExecutionError(already imported in both files) to preserve the trace context. Fall back toValueErrorwith clear message when no trace_id.Changes
backend/app/services/workflow_executor.py: Lock-protect reads inget_node_inputs_for_edgesandget_node_inputsbackend/app/services/node_execution/nodes/llm_node.py: Add try-except for non-batch JSON parsebackend/app/services/node_execution/nodes/agent_node.py: Add try-except for non-batch JSON parse