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/README.md b/README.md
index 0950d751..730d9c2f 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@
 </div>
 
 > [!WARNING]
-> You are currently looking at the readme of Haystack 2.0-Beta, an unstable version of what will eventually become Haystack 2.0. We are still maintaining Haystack 1.x which is the version of Haystack you should use in production. [Switch to Haystack 1.x, currently on 1.23 here](https://github.com/deepset-ai/haystack/tree/v1.x).
+> You are currently looking at the readme of Haystack 2.0-Beta, an unstable version of what will eventually become Haystack 2.0. We are still maintaining Haystack 1.x which is the version of Haystack you should use in production. [Switch to Haystack 1.x here](https://github.com/deepset-ai/haystack/tree/v1.x).
 
 [Haystack](https://haystack.deepset.ai/) is an end-to-end LLM framework that enables you to build applications powered by LLMs, Transformer models, vector search and more. Whether you want to perform retrieval-augmented generation (RAG), documentation search, question answering or answer generation, you can use state-of-the-art embedding models and LLMs with Haystack to build end-to-end NLP applications to solve your use case.
 
diff --git a/haystack/core/component/component.py b/haystack/core/component/component.py
index ab264ce1..9db5a7aa 100644
--- a/haystack/core/component/component.py
+++ b/haystack/core/component/component.py
@@ -147,6 +147,12 @@ class ComponentMeta(type):
                 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 4339db9e..eeecdcea 100644
--- a/haystack/core/pipeline/pipeline.py
+++ b/haystack/core/pipeline/pipeline.py
@@ -190,10 +190,19 @@ class Pipeline:
                 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 00000000..1ef05544
--- /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 b127b489..8a795629 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 a8238344..59e2088a 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 2a0d4f9b..5930f5e5 100644
--- a/test/core/pipeline/test_pipeline.py
+++ b/test/core/pipeline/test_pipeline.py
@@ -7,14 +7,27 @@ from typing import Optional
 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