Repository: deepset-ai/haystack

Issue:
Pipeline run order wrong
**Describe the bug**
When having a more complex pipeline the run order fails by not being able to identify the first node and then setting the "documents" and query input to empty strings. Nodes are executed multiple times overwriting these wrong intermediary outputs again during run time. 

The point of failure is in the `_component_has_enough_inputs_to_run` method of the pipeline.py, as expected inputs for prompt_builder1 are `question`, `template` and `template_variables` and the input parameters are just `question`, resulting in the function returning `false`. Later a different component is being executed with "default" values, which are all None / empty strings. Though the template is being parsed already upon instantiation to the prompt builder and the `template_variables` just include the `question` parsed in the run method. So no mismatch between expected and input parameters should be there. 

Parsing `template` and `template_variables` in the run method resolves this issue (shouldn't be needed though).


Output of the sample pipeline (nodes are executed multiple times and starting with the second llm): 

![image](https://github.com/deepset-ai/haystack/assets/87523290/7d2feea0-097d-49f4-8fd4-01e4723bce9f)

**To Reproduce**

Run this pipeline and check the execution order

```
from haystack.components.converters import TextFileToDocument
from haystack.components.preprocessors import DocumentSplitter
from haystack.components.retrievers.in_memory import InMemoryEmbeddingRetriever
from haystack.components.writers import DocumentWriter
from haystack.components.rankers import TransformersSimilarityRanker
from haystack.components.generators import OpenAIGenerator
from haystack.components.builders.prompt_builder import PromptBuilder
from haystack.document_stores.in_memory import InMemoryDocumentStore
from haystack import Pipeline
from dotenv import load_dotenv
import os
from haystack.components.embedders import SentenceTransformersTextEmbedder, SentenceTransformersDocumentEmbedder
import logging
from haystack.utils import Secret


logging.basicConfig()
logging.getLogger("haystack.core.pipeline.pipeline").setLevel(logging.DEBUG)

doc_store = InMemoryDocumentStore()
path = "../data/test_folder/"
pathlist = [path+x for x in os.listdir(path)]
converter = TextFileToDocument()

print(f"Documents: {doc_store.count_documents()}")

load_dotenv("ENV_PATH")
openai_api_key = Secret.from_env_var("OPENAI_API_KEY")

prompt_template1 = """
You are a spellchecking system. Check the given query and fill in the corrected query.

Question: {{question}}
Corrected question: 
"""
prompt_template2 = """
According to these documents:

{% for doc in documents %}
  {{ doc.content }}
{% endfor %}

Answer the given question: {{question}}
Answer:
"""

prompt_template3 = """
{% for ans in replies %}
  {{ ans }}
{% endfor %}
"""

prompt_builder1 = PromptBuilder(template=prompt_template1)
prompt_builder2 = PromptBuilder(template=prompt_template2)
prompt_builder3 = PromptBuilder(template=prompt_template3)

llm1 = OpenAIGenerator(api_key=openai_api_key)
llm2 = OpenAIGenerator(api_key=openai_api_key)

ranker = TransformersSimilarityRanker(top_k=5)
retriever = InMemoryEmbeddingRetriever(document_store=doc_store)
embedder = SentenceTransformersTextEmbedder(model="sentence-transformers/all-MiniLM-L6-v2")
doc_embedder = SentenceTransformersDocumentEmbedder(model="sentence-transformers/all-MiniLM-L6-v2")
splitter = DocumentSplitter(split_by="word", split_length=200, split_overlap=10)
writer = DocumentWriter(document_store=doc_store)

indexing_p = Pipeline()
indexing_p.add_component(name="converter", instance=converter)
indexing_p.add_component(name="splitter", instance=splitter)
indexing_p.add_component(name="DocEmbedder", instance=doc_embedder)
indexing_p.add_component(name="writer", instance=writer)

indexing_p.connect("converter.documents", "splitter")
indexing_p.connect("splitter.documents", "DocEmbedder.documents")
indexing_p.connect("DocEmbedder.documents", "writer.documents")

indexing_p.run({"converter": {"sources": pathlist}})


print(f"Documents: {doc_store.count_documents()}")

pipeline = Pipeline()
pipeline.add_component(name="TextEmbedder", instance=embedder)
pipeline.add_component(name="retriever", instance=retriever)
pipeline.add_component(name="ranker", instance=ranker)
pipeline.add_component(name="prompt_builder2", instance=prompt_builder2)
pipeline.add_component(name="prompt_builder1", instance=prompt_builder1)
pipeline.add_component(name="prompt_builder3", instance=prompt_builder3)
pipeline.add_component(name="llm", instance=llm1)
pipeline.add_component(name="spellchecker", instance=llm2)


pipeline.connect("prompt_builder1", "spellchecker")
pipeline.connect("spellchecker.replies", "prompt_builder3")
pipeline.connect("prompt_builder3", "TextEmbedder.text")
pipeline.connect("prompt_builder3", "ranker.query")
pipeline.connect("TextEmbedder", "retriever.query_embedding")
pipeline.connect("retriever", "ranker")
pipeline.connect("ranker", "prompt_builder2.documents")
pipeline.connect("prompt_builder3", "prompt_builder2.question")
pipeline.connect("prompt_builder2", "llm")

question = "Wha i Acromegaly?"
result = pipeline.run({
    "prompt_builder1": {"question": question}})
# print(result)
```
[test_data.zip](https://github.com/user-attachments/files/16110169/test_data.zip)

**FAQ Check**
- [x] Have you had a look at [our new FAQ page](https://docs.haystack.deepset.ai/docs/faq)?



Discussion:
I briefly investigated by bisecting. The last commit this Pipeline works is https://github.com/deepset-ai/haystack/commit/badb05b3abb09fa190049b31b975365d69dd0112, the bug seems introduced with the commit right after https://github.com/deepset-ai/haystack/commit/83d3970405085aae5b22dc0f715398077f1f71fc.

Seems like the changes to `PromptBuilder` in #7655 surfaced this bug.

I'm still not sure what's the actual cause and will keep investigating. 
Temporary workdaournd is adding `required_variables` in `PromptBuilder`s as done below makes the Pipeline run as expected.

```python
prompt_builder2 = PromptBuilder(template=prompt_template2, required_variables=["documents", "question"])
prompt_builder3 = PromptBuilder(template=prompt_template3, required_variables=["replies"])
```

Another solution could be changing the order the `PromptBuilder`s are added in the Pipeline:

```python
pipeline.add_component(name="prompt_builder1", instance=prompt_builder1)
pipeline.add_component(name="prompt_builder3", instance=prompt_builder3)
pipeline.add_component(name="prompt_builder2", instance=prompt_builder2)
```

This problems is caused by a combination of some things. The way we decide which Component to run next, the fact that Components addition order influences the run order and how we treat Components that have only inputs with defaults.

Ideally the fix would change how we decide which Components to run that is independent from the other two factors. And also doesn't break existing use cases.

Not sure how easy that will be. 😕 



Resolving patch:
diff --git a/haystack/core/pipeline/base.py b/haystack/core/pipeline/base.py
--- a/haystack/core/pipeline/base.py
+++ b/haystack/core/pipeline/base.py
@@ -767,6 +767,18 @@ def _init_inputs_state(self, data: Dict[str, Dict[str, Any]]) -> Dict[str, Dict[
 
     def _init_run_queue(self, pipeline_inputs: Dict[str, Any]) -> List[Tuple[str, Component]]:
         run_queue: List[Tuple[str, Component]] = []
+
+        # HACK: Quick workaround for the issue of execution order not being
+        # well-defined (NB - https://github.com/deepset-ai/haystack/issues/7985).
+        # We should fix the original execution logic instead.
+        if networkx.is_directed_acyclic_graph(self.graph):
+            # If the Pipeline is linear we can easily determine the order of execution with
+            # a topological sort.
+            # So use that to get the run order.
+            for node in networkx.topological_sort(self.graph):
+                run_queue.append((node, self.graph.nodes[node]["instance"]))
+            return run_queue
+
         for node_name in self.graph.nodes:
             component = self.graph.nodes[node_name]["instance"]
 


Test patch:
diff --git a/test/core/pipeline/features/pipeline_run.feature b/test/core/pipeline/features/pipeline_run.feature
--- a/test/core/pipeline/features/pipeline_run.feature
+++ b/test/core/pipeline/features/pipeline_run.feature
@@ -37,6 +37,7 @@ Feature: Pipeline running
         | that has a component with default inputs that doesn't receive anything from its sender |
         | that has a component with default inputs that doesn't receive anything from its sender but receives input from user |
         | that has a loop and a component with default inputs that doesn't receive anything from its sender but receives input from user |
+        | that has multiple components with only default inputs and are added in a different order from the order of execution |
 
     Scenario Outline: Running a bad Pipeline
         Given a pipeline <kind>
diff --git a/test/core/pipeline/features/test_run.py b/test/core/pipeline/features/test_run.py
--- a/test/core/pipeline/features/test_run.py
+++ b/test/core/pipeline/features/test_run.py
@@ -423,7 +423,7 @@ def pipeline_that_has_three_branches_that_dont_merge():
             PipelineRunData(
                 inputs={"add_one": {"value": 1}},
                 expected_outputs={"add_one_again": {"result": 6}, "add_ten": {"result": 12}, "double": {"value": 4}},
-                expected_run_order=["add_one", "repeat", "double", "add_ten", "add_three", "add_one_again"],
+                expected_run_order=["add_one", "repeat", "add_ten", "double", "add_three", "add_one_again"],
             )
         ],
     )
@@ -448,7 +448,7 @@ def pipeline_that_has_two_branches_that_merge():
             PipelineRunData(
                 inputs={"first_addition": {"value": 1}, "third_addition": {"value": 1}},
                 expected_outputs={"fourth_addition": {"result": 3}},
-                expected_run_order=["first_addition", "second_addition", "third_addition", "diff", "fourth_addition"],
+                expected_run_order=["first_addition", "third_addition", "second_addition", "diff", "fourth_addition"],
             )
         ],
     )
@@ -633,7 +633,7 @@ def run(self, messages: List[ChatMessage]):
                     },
                     "mm2": {"merged_message": "Fake message"},
                 },
-                expected_run_order=["prompt_builder", "mm1", "llm", "mm2"],
+                expected_run_order=["prompt_builder", "llm", "mm1", "mm2"],
             )
         ],
     )
@@ -1021,7 +1021,7 @@ def pipeline_that_has_multiple_branches_of_different_lengths_that_merge_into_a_c
             PipelineRunData(
                 inputs={"first_addition": {"value": 1}, "third_addition": {"value": 1}},
                 expected_outputs={"fourth_addition": {"result": 12}},
-                expected_run_order=["first_addition", "second_addition", "third_addition", "sum", "fourth_addition"],
+                expected_run_order=["first_addition", "third_addition", "second_addition", "sum", "fourth_addition"],
             )
         ],
     )
@@ -1376,3 +1376,109 @@ def run(self, prompt: str):
             )
         ],
     )
+
+
+@given(
+    "a pipeline that has multiple components with only default inputs and are added in a different order from the order of execution",
+    target_fixture="pipeline_data",
+)
+def pipeline_that_has_multiple_components_with_only_default_inputs_and_are_added_in_a_different_order_from_the_order_of_execution():
+    prompt_builder1 = PromptBuilder(
+        template="""
+    You are a spellchecking system. Check the given query and fill in the corrected query.
+
+    Question: {{question}}
+    Corrected question:
+    """
+    )
+    prompt_builder2 = PromptBuilder(
+        template="""
+    According to these documents:
+
+    {% for doc in documents %}
+    {{ doc.content }}
+    {% endfor %}
+
+    Answer the given question: {{question}}
+    Answer:
+    """
+    )
+    prompt_builder3 = PromptBuilder(
+        template="""
+    {% for ans in replies %}
+    {{ ans }}
+    {% endfor %}
+    """
+    )
+
+    @component
+    class FakeRetriever:
+        @component.output_types(documents=List[Document])
+        def run(
+            self,
+            query: str,
+            filters: Optional[Dict[str, Any]] = None,
+            top_k: Optional[int] = None,
+            scale_score: Optional[bool] = None,
+        ):
+            return {"documents": [Document(content="This is a document")]}
+
+    @component
+    class FakeRanker:
+        @component.output_types(documents=List[Document])
+        def run(
+            self,
+            query: str,
+            documents: List[Document],
+            top_k: Optional[int] = None,
+            scale_score: Optional[bool] = None,
+            calibration_factor: Optional[float] = None,
+            score_threshold: Optional[float] = None,
+        ):
+            return {"documents": documents}
+
+    @component
+    class FakeGenerator:
+        @component.output_types(replies=List[str], meta=Dict[str, Any])
+        def run(self, prompt: str, generation_kwargs: Optional[Dict[str, Any]] = None):
+            return {"replies": ["This is a reply"], "meta": {"meta_key": "meta_value"}}
+
+    pipeline = Pipeline()
+    pipeline.add_component(name="retriever", instance=FakeRetriever())
+    pipeline.add_component(name="ranker", instance=FakeRanker())
+    pipeline.add_component(name="prompt_builder2", instance=prompt_builder2)
+    pipeline.add_component(name="prompt_builder1", instance=prompt_builder1)
+    pipeline.add_component(name="prompt_builder3", instance=prompt_builder3)
+    pipeline.add_component(name="llm", instance=FakeGenerator())
+    pipeline.add_component(name="spellchecker", instance=FakeGenerator())
+
+    pipeline.connect("prompt_builder1", "spellchecker")
+    pipeline.connect("spellchecker.replies", "prompt_builder3")
+    pipeline.connect("prompt_builder3", "retriever.query")
+    pipeline.connect("prompt_builder3", "ranker.query")
+    pipeline.connect("retriever.documents", "ranker.documents")
+    pipeline.connect("ranker.documents", "prompt_builder2.documents")
+    pipeline.connect("prompt_builder3", "prompt_builder2.question")
+    pipeline.connect("prompt_builder2", "llm")
+
+    return (
+        pipeline,
+        [
+            PipelineRunData(
+                inputs={"prompt_builder1": {"question": "Wha i Acromegaly?"}},
+                expected_outputs={
+                    "llm": {"replies": ["This is a reply"], "meta": {"meta_key": "meta_value"}},
+                    "spellchecker": {"meta": {"meta_key": "meta_value"}},
+                },
+                expected_run_order=[
+                    "prompt_builder1",
+                    "spellchecker",
+                    "prompt_builder3",
+                    "retriever",
+                    "ranker",
+                    "prompt_builder2",
+                    "llm",
+                ],
+            )
+        ],
+    )
diff --git a/test/core/pipeline/test_pipeline.py b/test/core/pipeline/test_pipeline.py
--- a/test/core/pipeline/test_pipeline.py
+++ b/test/core/pipeline/test_pipeline.py
@@ -801,12 +801,13 @@ def test__init_run_queue(self):
 
         data = {"yet_another_with_single_input": {"in": 1}}
         run_queue = pipe._init_run_queue(data)
-        assert len(run_queue) == 5
-        assert run_queue[0][0] == "with_variadic"
-        assert run_queue[1][0] == "with_no_inputs"
-        assert run_queue[2][0] == "with_single_input"
-        assert run_queue[3][0] == "yet_another_with_single_input"
+        assert len(run_queue) == 6
+        assert run_queue[0][0] == "with_no_inputs"
+        assert run_queue[1][0] == "with_single_input"
+        assert run_queue[2][0] == "yet_another_with_single_input"
+        assert run_queue[3][0] == "another_with_single_input"
         assert run_queue[4][0] == "with_multiple_inputs"
+        assert run_queue[5][0] == "with_variadic"
 
     def test__init_inputs_state(self):
         pipe = Pipeline()