Repository: deepset-ai/haystack

Issue:
Warn users when adding the same component instance to multiple pipelines
Adding the same component instance to different pipelines causes side effects that often result in unexpected behaviour.

Except for any operation done during the `warm_up` phase, components are stateless so there's no gain in adding the same instance to different pipelines, and our stance at this point in time is we don't support it.

We need to document this and add a meaningful error in `add_component` to detect if an instance is already part of another pipeline, and in case failing the operation explaining why.


Discussion:

Resolving patch:
diff --git a/haystack/core/component/component.py b/haystack/core/component/component.py
--- a/haystack/core/component/component.py
+++ b/haystack/core/component/component.py
@@ -147,6 +147,12 @@ def __call__(cls, *args, **kwargs):
                 if run_signature.parameters[param].default != inspect.Parameter.empty:
                     socket_kwargs["default_value"] = run_signature.parameters[param].default
                 instance.__haystack_input__[param] = InputSocket(**socket_kwargs)
+
+        # Since a Component can't be used in multiple Pipelines at the same time
+        # we need to know if it's already owned by a Pipeline when adding it to one.
+        # We use this flag to check that.
+        instance.__haystack_added_to_pipeline__ = None
+
         return instance
 
 
diff --git a/haystack/core/pipeline/pipeline.py b/haystack/core/pipeline/pipeline.py
--- a/haystack/core/pipeline/pipeline.py
+++ b/haystack/core/pipeline/pipeline.py
@@ -190,10 +190,19 @@ def add_component(self, name: str, instance: Component) -> None:
                 f"'{type(instance)}' doesn't seem to be a component. Is this class decorated with @component?"
             )
 
+        if getattr(instance, "__haystack_added_to_pipeline__", None):
+            msg = (
+                "Component has already been added in another Pipeline. "
+                "Components can't be shared between Pipelines. Create a new instance instead."
+            )
+            raise PipelineError(msg)
+
         # Create the component's input and output sockets
         input_sockets = getattr(instance, "__haystack_input__", {})
         output_sockets = getattr(instance, "__haystack_output__", {})
 
+        setattr(instance, "__haystack_added_to_pipeline__", self)
+
         # Add component to the graph, disconnected
         logger.debug("Adding component '%s' (%s)", name, instance)
         self.graph.add_node(


Test patch:
diff --git a/test/core/pipeline/test_connections.py b/test/core/pipeline/test_connections.py
--- a/test/core/pipeline/test_connections.py
+++ b/test/core/pipeline/test_connections.py
@@ -338,11 +338,12 @@ def test_connect_receiver_socket_does_not_exist():
 def test_connect_many_outputs_to_the_same_input():
     add_1 = AddFixedValue()
     add_2 = AddFixedValue()
+    add_3 = AddFixedValue()
 
     pipe = Pipeline()
     pipe.add_component("first", add_1)
     pipe.add_component("second", add_2)
-    pipe.add_component("third", add_2)
+    pipe.add_component("third", add_3)
     pipe.connect("first.result", "second.value")
     with pytest.raises(PipelineConnectError, match=r"second.value is already connected to \['first'\]"):
         pipe.connect("third.result", "second.value")
diff --git a/test/core/pipeline/test_distinct_loops_pipeline.py b/test/core/pipeline/test_distinct_loops_pipeline.py
--- a/test/core/pipeline/test_distinct_loops_pipeline.py
+++ b/test/core/pipeline/test_distinct_loops_pipeline.py
@@ -24,8 +24,6 @@ def test_pipeline_equally_long_branches():
     pipeline.connect("add_two", "multiplexer.value")
     pipeline.connect("add_one", "multiplexer.value")
 
-    pipeline.draw(Path(__file__).parent / Path(__file__).name.replace(".py", ".png"))
-
     results = pipeline.run({"multiplexer": {"value": 0}})
     assert results == {"remainder": {"remainder_is_0": 0}}
 
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
@@ -7,14 +7,27 @@
 import pytest
 
 from haystack.core.component.sockets import InputSocket, OutputSocket
-from haystack.core.errors import PipelineError, PipelineMaxLoops, PipelineRuntimeError
+from haystack.core.errors import PipelineError, PipelineRuntimeError
 from haystack.core.pipeline import Pipeline
 from haystack.testing.factory import component_class
-from haystack.testing.sample_components import AddFixedValue, Double, Sum, Threshold
+from haystack.testing.sample_components import AddFixedValue, Double
 
 logging.basicConfig(level=logging.DEBUG)
 
 
+def test_add_component_to_different_pipelines():
+    first_pipe = Pipeline()
+    second_pipe = Pipeline()
+    some_component = component_class("Some")()
+
+    assert some_component.__haystack_added_to_pipeline__ is None
+    first_pipe.add_component("some", some_component)
+    assert some_component.__haystack_added_to_pipeline__ is first_pipe
+
+    with pytest.raises(PipelineError):
+        second_pipe.add_component("some", some_component)
+
+
 def test_run_with_component_that_does_not_return_dict():
     BrokenComponent = component_class(
         "BrokenComponent", input_types={"a": int}, output_types={"b": int}, output=1  # type:ignore