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:

Patch:
diff --git a/haystack/core/component/component.py b/haystack/core/component/component.py
index ab264ce11a..9db5a7aabc 100644
--- 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
index 4339db9e03..eeecdcea49 100644
--- 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(
diff --git a/releasenotes/notes/components-reuse-ccc5f2d4b9d7f9ff.yaml b/releasenotes/notes/components-reuse-ccc5f2d4b9d7f9ff.yaml
new file mode 100644
index 0000000000..1ef0554411
--- /dev/null
+++ b/releasenotes/notes/components-reuse-ccc5f2d4b9d7f9ff.yaml
@@ -0,0 +1,4 @@
+---
+enhancements:
+  - |
+    Change `Pipeline.add_component()` to fail if the `Component` instance has already been added in another `Pipeline`.


Test patch:
diff --git a/test/core/pipeline/test_connections.py b/test/core/pipeline/test_connections.py
index b127b48969..8a79562996 100644
--- 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
index a82383446c..59e2088a48 100644
--- 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
index 2a0d4f9b89..5930f5e570 100644
--- 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