Skip to content

fix: thread-safe node input reads and JSON parse error handling - #292

Merged
ckakgun merged 3 commits into
heymrun:mainfrom
isheng-eqi:fix/thread-safe-node-inputs-and-json-parse
Jul 7, 2026
Merged

fix: thread-safe node input reads and JSON parse error handling#292
ckakgun merged 3 commits into
heymrun:mainfrom
isheng-eqi:fix/thread-safe-node-inputs-and-json-parse

Conversation

@isheng-eqi

Copy link
Copy Markdown
Contributor

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_edges and get_node_inputs read shared dicts (node_outputs, node_execution_contexts, skipped_nodes) without acquiring self.lock, while store_node_output writes these dicts under lock and _build_context correctly 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] = output
  • get_node_inputs_for_edges (line 2373): source_id in self.node_outputs — no lock
  • _build_context (line 5476): with self.lock: ...snapshot... — correct pattern

Fix: Snapshot node_outputs, node_execution_contexts, and skipped_nodes under self.lock before iteration, matching the existing _build_context pattern.

Problem 2: Missing JSON parse error handling (severity: medium)

llm_node.py:179 and agent_node.py:46 call self._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 at llm_node.py:163-168 already handles this correctly per-item.

Fix: Wrap _parse_json_output in try-except. When trace_id is available, raise NodeTraceableExecutionError (already imported in both files) to preserve the trace context. Fall back to ValueError with clear message when no trace_id.

Changes

  • backend/app/services/workflow_executor.py: Lock-protect reads in get_node_inputs_for_edges and get_node_inputs
  • backend/app/services/node_execution/nodes/llm_node.py: Add try-except for non-batch JSON parse
  • backend/app/services/node_execution/nodes/agent_node.py: Add try-except for non-batch JSON parse

… 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.
@ckakgun

ckakgun commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

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:

  • LLM and Agent JSON parse failures preserve trace_id when available, and raise a clear ValueError without one.
  • Two parallel upstream nodes feeding one downstream node collect both inputs.
  • Loop execution and re-execution still work with the input snapshot change.
  • output allowDownstream returns early while downstream continues.
  • Background / do-not-wait execution still records downstream results.

One lock-discipline concern: skipped_nodes is now read via a locked snapshot, but some writes still happen outside self.lock, for example branch routing updates. Please either lock those writes too, or document/test why those writes cannot overlap the reads.

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
@isheng-eqi

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review @ckakgun! All feedback addressed:

Lock discipline fix: Added with self.lock protection around all skipped_nodes mutations that can overlap with the locked snapshot reads:

  • Branch routing in execute_node_parallel (both success and error paths)
  • Condition/switch skip_branch_targets_preserving_shared_downstream calls
  • reset_nodes_for_execution and prepare_loop_for_reexecution discard operations

Tests: Added 9 tests covering all 5 requested scenarios in test_workflow_executor_thread_safety.py:

  1. LLM & Agent JSON parse failures preserve trace_id → NodeTraceableExecutionError; raise ValueError without trace_id
  2. Two parallel upstream nodes (branchA/branchB) feed one downstream merge node — verifies both inputs collected correctly
  3. Loop with 3-iteration array — verifies re-execution works with the input snapshot change, all 4 loop results (3 iterations + 1 done) recorded
  4. Output node with allowDownstream: true — verifies it completes and workflow returns success
  5. Wait node with doNotWait: true — verifies downstream nodes still record results

All 20 tests pass (9 new + 11 existing branching regression). Ruff lint clean.

Let me know if anything else is needed!

@ckakgun

ckakgun commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

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, ruff check . passes, and the local smokes for JSON trace failures plus repeated two-parallel-upstream joins pass 👍

Two things before merge:

  1. uv run ruff format --check . still fails because backend/tests/test_workflow_executor_thread_safety.py would be reformatted. Please run Ruff format and push the formatting-only diff, since this blocks the check gate.

  2. The skipped_nodes lock fix still seems to cover only part of the write paths. The locked condition/switch blocks in workflow_executor.py are under if self.test_mode and node_data.get("pinnedData") is not None, so they cover the pinned/test-mode preview path. Normal execution dispatches through the modular handlers, and those handlers still call skip_branch_targets_preserving_shared_downstream or mutate skipped_nodes without self.lock, for example condition, loop, switch, and disable-node handlers.

Tests pass, so this does not look like an immediate functional break under CPython, but the claim that skipped_nodes writes are lock-protected does not hold for the actual runtime handler paths. Please lock those handler writes too, or document why they cannot overlap with the locked snapshot reads.

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.
@isheng-eqi

Copy link
Copy Markdown
Contributor Author

Good catches, both fixed:

  1. Ruff format: reformatted — ruff format --check now passes on all changed files.

  2. Handler lock coverage: good catch on the test_mode path. Extended with self.lock to the actual modular handlers that write skipped_nodes during normal execution:

    • condition_node.py — both true/false branch paths
    • switch_node.py — selected-handle branch path
    • loop_node.py — both loop/done branch paths
    • disable_node_node.pyskipped_nodes.add()

All 20 tests still pass, ruff format and lint clean. Let me know if anything else!

@ckakgun ckakgun left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, the latest commit addresses both points. Looks merge-safe from my side. 🤗

@ckakgun
ckakgun enabled auto-merge (squash) July 7, 2026 11:22
@ckakgun ckakgun added the next label Jul 7, 2026
@ckakgun
ckakgun merged commit f746ae1 into heymrun:main Jul 7, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Development

Successfully merging this pull request may close these issues.

2 participants