[1.15.0] ResponseStreamFilter.initialize() causes multi-second delay before GraphRunStarted on branch-heavy workflows
- Dominant language
- TypeScript
- Stars
- 156k
- Forks
- 24.6k
- Avg merge
- 22h 9m
- Merged PRs (30d)
- 610
Description
### Self Checks
- [x] I have read the [Contributing Guide](https://github.com/langgenius/dify/blob/main/CONTRIBUTING.md) and [Language Policy](https://github.com/langgenius/dify/issues/1542).
- [x] This is only for bug report, if you would like to ask a question, please head to [Discussions](https://github.com/langgenius/dify/discussions/categories/general).
- [x] I have searched for existing issues, including closed ones. This is a focused follow-up to #38852 with an isolated root cause and A/B measurements.
- [x] I confirm that I am using English to submit this report, otherwise it will be closed.
- [x] 【中文用户 & Non English User】Please use English to submit.
- [x] Please do not modify this template :) and fill in all the required fields.
### Dify version
1.15.0
Graphon: `0.5.3`
### Cloud or Self Hosted
Self Hosted (Docker)
### Steps to reproduce
This is a focused follow-up to #38852. The ~6 second delay reported there before a called workflow starts can be reproduced and isolated to `ResponseStreamFilter.initialize()` on a sufficiently branch-heavy workflow.
#### Test graph
The real graph used for the benchmark contains:
- 157 nodes
- 203 edges
- 9 response nodes
No application IDs, workflow contents, user prompts, credentials, model settings or private data are included here.
#### Root cause
In Dify 1.15.0, workflow execution is wrapped by `ResponseStreamFilter`. During `ResponseStreamFilter.initialize()`, response-path information is prepared before the first `GraphRunStarted` event is emitted.
For each response node, the current implementation enumerates complete paths from the graph root to that response. The DFS explores outgoing branches even when those branches cannot reach the target response. On branch/merge-heavy DAGs, this causes a very large number of unnecessary recursive calls.
After path enumeration, blocking-edge classification is also repeated for the same edges across many complete paths.
The observed critical path is therefore:
```text
request
-> workflow preparation
-> ResponseStreamFilter.initialize()
-> complete-path traversal
-> repeated blocking-edge classification
-> GraphRunStarted
-> first workflow node
```
Because this happens before `GraphRunStarted`, the delay is not attributed to any individual workflow node.
#### Isolated A/B benchmark
Original implementation:
```text
ResponseStreamFilter.initialize(): 6.414 s
First workflow event: 7.086 s
```
Corrected implementation:
```text
ResponseStreamFilter.initialize(): 0.409 s
First workflow event: 1.081 s
```
Difference:
```text
Filter initialization: -6.005 s
First workflow event: -6.005 s
```
That is approximately a 93.6% reduction in filter initialization time.
A separate matched run against the same real graph measured:
```text
Original: 6.709 s
Corrected: 0.409 s
```
#### Proposed solution
The tested correction keeps the current complete-path representation and response semantics, but avoids work that cannot contribute to a valid root-to-response path.
**1. Reverse-reachability pruning per response node**
Before the existing forward DFS for a response node, compute the set of nodes that can reach that response by traversing incoming edges backwards.
Conceptually:
```python
reachable_to_response = reverse_reachable_nodes(response_node_id)
```
Then prune outgoing edges whose target cannot reach the response:
```python
for edge in graph.get_outgoing_edges(current_node_id):
if edge.head not in reachable_to_response:
continue
# existing DFS behavior
```
Any node that cannot reach the target response cannot be part of a valid root-to-response path, so this pruning does not remove valid complete paths.
**2. Cache edge blocking classification within one response calculation**
Blocking-edge classification can be cached for the duration of one response-node calculation instead of recalculating it for the same edge on every enumerated path.
Conceptually:
```python
blocking_edge_cache: dict[str, bool] = {}
def is_blocking(edge):
if edge.id not in blocking_edge_cache:
blocking_edge_cache[edge.id] = classify_edge(edge, variable_selectors)
return blocking_edge_cache[edge.id]
```
The cache is intentionally local to one response calculation. It is not cross-workflow, cross-request, persistent, or cross-tenant.
#### Semantic-equivalence validation
The correction does **not** bypass `ResponseStreamFilter`, change graph scheduling, or change workflow nodes.
For the real graph, the original and corrected implementations produced exactly the same:
- ordered complete paths
- ordered blocking paths
- path multiplicity
- edge ordering
The ordered blocking-path representation produced the same SHA-256 digest in the original and corrected implementations:
```text
7aaf2a6a3984331671210c76ed1ecf88f79efb40fde73082b724b9ed7c9c3677
```
#### Regression tests
Seven local tests passed against the unmodified Graphon 0.5.3 implementation as the oracle, including:
- 100 deterministic seeded random graphs
- cycles
- parallel edges
- disconnected response targets
- root response nodes
- initial paths
- an operation-count regression for unreachable diamond branches
The tests import the corrected implementation directly and compare its result with the original installed Graphon behavior; the patched implementation is not used as its own oracle.
#### End-to-end validation
The patched build was then exercised through the normal public HTTP API and normal Celery workflow path on a self-hosted staging deployment.
Sanitized results:
| Run | First workflow event | Total | Result |
| --- | ---: | ---: | --- |
| A | 1.669 s | 12.186 s | succeeded, zero exceptions |
| B | 1.154 s | 10.805 s | succeeded, zero exceptions |
| C | 1.184 s | 7.952 s | succeeded, state/recall behavior preserved |
| D | 1.134 s | 3.871 s | succeeded, zero exceptions |
| E | 0.705 s | 13.447 s | succeeded through a tool-driven branch, zero exceptions |
Fresh unpatched startup samples from the same class of workflow were:
```text
9.803 s
9.693 s
6.977 s
```
These are diagnostic samples, not a p95/capacity benchmark. Model/tool/network/database time remains; this fix only targets path-preparation latency before workflow execution starts.
#### Scope limitation
This is not a general bounded-memory replacement for Graphon's complete-path representation.
If a graph genuinely contains an exponential number of valid root-to-response paths, complete-path enumeration can still be exponential. The proposed change only prunes branches that provably cannot reach the current response and avoids redundant edge classification.
I am also **not** claiming this explains every source of workflow-level overhead described in #38852, especially the separate difference between total workflow duration and the sum of node durations.
I can provide the minimal Graphon 0.5.3 patch and regression tests, and can adapt the same optimization to the current Graphon implementation if maintainers prefer an upstream PR there.
### ✔️ Expected Behavior
`ResponseStreamFilter.initialize()` should not spend multiple seconds exploring branches that cannot reach the response node being calculated.
For branch-heavy workflows, initialization should be limited to graph regions that can actually participate in valid paths to each response while preserving the current ordered path semantics and multiplicity.
On the tested graph, initialization should be around the corrected measurement (~0.4 s) rather than several seconds before `GraphRunStarted`.
### ❌ Actual Behavior
With Dify 1.15.0 / Graphon 0.5.3, `ResponseStreamFilter.initialize()` spends multiple seconds enumerating paths through branches that cannot reach the current response and repeatedly classifying the same edges.
On the tested 157-node / 203-edge / 9-response graph:
```text
ResponseStreamFilter.initialize(): 6.414 s
First workflow event: 7.086 s
```
With reachability pruning and per-response edge-classification caching:
```text
ResponseStreamFilter.initialize(): 0.409 s
First workflow event: 1.081 s
```
The exact 6.005-second reduction in filter initialization was reflected in the first workflow event, which strongly localizes this portion of the regression to response-filter initialization before graph execution starts.
Contributor guide
Research direction
Start at Graphon's ResponseStreamFilter.initialize() and trace the path calculation before GraphRunStarted. Compare the current implementation with the reported corrected behavior, then run the seven regression tests described in the issue, including random graphs, cycles, parallel edges, disconnected targets, root responses, initial paths, and unreachable branches. Done means preserving ordered paths and blocking-edge semantics while matching the measured initialization improvement.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- backend, performance
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 55/100