Parallel Workflow Execution#
Dify's workflow engine (graphon) runs parallel branches by spawning worker threads from a configurable thread pool. Three distinct classes of runtime hazards have produced production bugs: Flask application context loss in worker threads, in-place credential mutation that corrupts shared ToolRuntime state, and variable pool / run-context propagation from parent graphs to child sub-graphs.
Key entry points:
WorkflowEntry.__init__β bootstraps theGraphEngine, captures the current execution context, and layers on execution limits and observability.WorkflowAppRunner.runβ constructs theVariablePool, delegates graph initialization to_init_graph(), then drivesWorkflowEntry.DifyNodeFactoryβ creates every node in the graph; child engines for iteration/loop sub-graphs callDifyNodeFactory.with_runtime_state()to obtain a factory scoped to the child's state.
Flask Application Context Loss in Worker Threads#
Symptom: When an Iteration or parallel branch node runs LLM or HTTP-request sub-nodes on worker threads, those threads raise "Working outside of application context." . The error is reproducible with is_parallel: true and multiple concurrent workers.
Root cause: Python's contextvars and Flask's app context are thread-local; worker threads spawned by the graph engine's thread pool start with a blank slate.
How Dify solves it:
capture_current_context()is called on the parent thread at engine construction time. It callscontextvars.copy_context()and β via a registered Flask adapter β captures the Flask app context andflask_loginuser proxy.- The snapshot is stored on
GraphRuntimeState._execution_contextimmediately before the engine starts . - Graphon's worker pool restores that snapshot inside each worker thread, replaying all ContextVar values via
var.set(val)and pushing a new Flaskapp_context(). This pattern was systematized in PR #30607 .
The legacy helper preserve_flask_contexts(flask_app, context_vars) does the same work for the non-Celery worker-thread path used by AdvancedChatAppGenerator .
Ongoing gaps: Issues #38299 and #39977 indicate that sub-nodes inside deeply nested parallel iterations on v1.15β1.16 can still lose context. The workaround is to disable parallel mode (is_parallel: false) until thread-context restoration is fully recursive.
Database sessions: A related fix (PR #35855) migrated node construction DB queries from Flask-SQLAlchemy's current_app-bound session to session_factory.create_session(). The fetch_memory() function in node_factory.py explicitly documents this requirement .
Credential Mutation: Bearer Prefix Accumulation#
Symptom: A custom API tool called multiple times (agent loop, workflow retry, or parallel branch) sends an escalating Authorization header β Bearer my-token on the first call, Bearer Bearer my-token on the second, etc. β causing 401/403 failures on every call after the first.
Root cause: ApiTool.fork_tool_runtime() passes the ToolRuntime object by reference, so the parent and all forked tools share the same credentials dict. The old assembling_request() wrote the prefixed value back into that shared dict:
# buggy β mutates the shared dict
credentials["api_key_value"] = f"Bearer {credentials['api_key_value']}"
Because the dict is never reset between invocations, each call finds an already-prefixed value and prepends again.
Fix (merged in PR #38616): Use a local variable; leave credentials unchanged:
api_key_value = credentials["api_key_value"]
if api_key_header_prefix == "bearer" and api_key_value:
api_key_value = f"Bearer {api_key_value}"
headers[api_key_header] = api_key_value # credentials dict never touched
Earlier attempts: PR #35997 and PR #36350 proposed the same pattern; PR #38616 is the one that merged. The current implementation is in api/core/tools/custom_tool/tool.py.
Broader lesson: Any mutable dict or Pydantic model passed by reference through fork_tool_runtime() is shared state. Avoid in-place mutation; prefer constructing a new value and assigning it to a local variable before use.
Variable Pool and Run-Context Propagation to Child Graphs#
Parallel branches and container nodes (Iteration, Loop) spawn independent child GraphEngine instances. Two objects must reach every child correctly:
VariablePool#
The pool is bootstrapped once in WorkflowAppRunner.run() from build_bootstrap_variables() and held on GraphRuntimeState . Child engines share the parent's pool by default, so outputs written by a child node with variable_pool.add((node_id, key), value) are immediately visible to downstream sibling nodes in the parent graph.
Known hazard: before PR #30059, loop-iteration outputs were never cleared between cycles, so a subsequent iteration could read stale prior-cycle values. The fix added _clear_loop_subgraph_variables() at the start of each cycle.
DifyRunContext / run_context#
User identity (tenant_id, app_id, user_id, user_from, invoke_from, trace_session_id) is assembled into a DifyRunContext by build_dify_run_context(), then wrapped in a DifyGraphInitContext frozen dataclass . This is translated to Graphon's GraphInitParams via to_graph_init_params() and stored on every DifyNodeFactory instance.
Child engines inherit run_context directly from the parent β no reconstruction needed β via DifyNodeFactory.with_runtime_state(child_graph_runtime_state) . All child nodes access identity through resolve_dify_run_context(run_context) in node_runtime.py, which raises ValueError on a missing key rather than silently producing empty state.
Bug: empty user_id in single-node debug runs β PR #34044 fixed a path where _prepare_single_node_execution() hardcoded user_id="", causing DatasetQuery.created_by to fail UUID validation inside iteration subgraphs.
Mutable account state (PR #39283): A refactor removed user.set_tenant_id(tenant_id) calls from Celery worker paths. Repository factory constructors now receive an explicit tenant_id: str argument, eliminating reliance on mutable ORM state that does not survive thread/process boundaries.
Architecture sketch#
WorkflowAppRunner.run()
ββ build_dify_run_context() β DifyGraphInitContext β DifyNodeFactory
ββ GraphEngine (parent)
ββ VariablePool βββββββββββββββββββ shared with children
ββ parallel branch / iteration node
ββ child GraphEngine
ββ DifyNodeFactory.with_runtime_state(child_state)
ββ same run_context ββ same tenant/user identity