Repository: deepset-ai/haystack

Issue:
AWS OpenSearch - deepcopy error on pipeline.add_node with refreshable_credentials
Hi,

I want to use an AWS OpenSearch instance as a DocumentStore. To be able to setup up a longer running service, I want to use refreshable credentials like so: 

```python
import boto3
import certifi
from haystack.document_stores import OpenSearchDocumentStore

from requests_aws4auth import AWS4Auth

from haystack.nodes import FARMReader
from haystack.nodes.retriever.sparse import BM25Retriever
from haystack.pipelines.standard_pipelines import ExtractiveQAPipeline

credentials = boto3.Session().get_credentials()
access_key = credentials.access_key
secret_key = credentials.secret_key
service = "es"

auth = AWS4Auth(
    access_key,
    secret_key,
    region="eu-central-1",
    service=service,
    refreshable_credentials=credentials,
)
document_store = OpenSearchDocumentStore(
    host="my-opensearch.eu-central-1.es.amazonaws.com",
    aws4auth=auth,
    ca_certs=certifi.where(),
    port=443,
)
retriever = BM25Retriever(document_store=document_store)
pipeline = ExtractiveQAPipeline(retriever=retriever, reader=reader)
```

This leads to a `TypeError: can't pickle SSLContext objects` though.

The problem is, that in `ExtractiveQAPipeline` and `self.pipeline.add_node` and `get_component_definitions` a `copy.deepcopy(pipeline_config["components"])` is attempted, but you can't copy an `ssl.SSLContext` object (by design) which is in the `AWS4Auth` object which is in the `OpenSearchDocumentStore`.

So the question is, is the `deepcopy` really necessary or is there a way around it to not copy the `AWS4Auth` object?

My hacky workaround right now is to setup the pipeline with an `InMemoryDocumentStore` and then exchanging it afterwards.

```python
    temp_doc_store = InMemoryDocumentStore()
    retriever = BM25Retriever(document_store=temp_doc_store)
    pipeline = ExtractiveQAPipeline(retriever=retriever, reader=reader)
    pipeline.get_node("Retriever").document_store = document_store # Here the documentstore is replaced
```

I am using haystack version 1.4.0.

Here is the full stack trace of the deepcopy error:

```text
Traceback (most recent call last):
  File "/usr/lib64/python3.7/runpy.py", line 193, in _run_module_as_main
    "__main__", mod_spec)
  File "/usr/lib64/python3.7/runpy.py", line 85, in _run_code
    exec(code, run_globals)
  File "/home/ec2-user/.vscode-server/extensions/ms-python.python-2022.6.2/pythonFiles/lib/python/debugpy/__main__.py", line 45, in <module>
    cli.main()
  File "/home/ec2-user/.vscode-server/extensions/ms-python.python-2022.6.2/pythonFiles/lib/python/debugpy/../debugpy/server/cli.py", line 444, in main
    run()
  File "/home/ec2-user/.vscode-server/extensions/ms-python.python-2022.6.2/pythonFiles/lib/python/debugpy/../debugpy/server/cli.py", line 285, in run_file
    runpy.run_path(target_as_str, run_name=compat.force_str("__main__"))
  File "/usr/lib64/python3.7/runpy.py", line 263, in run_path
    pkg_name=pkg_name, script_name=fname)
  File "/usr/lib64/python3.7/runpy.py", line 96, in _run_module_code
    mod_name, mod_spec, pkg_name, script_name)
  File "/usr/lib64/python3.7/runpy.py", line 85, in _run_code
    exec(code, run_globals)
  File "/home/ec2-user/nlp-tmrs-search-service/search_pipeline/docstore_test.py", line 33, in <module>
    pipeline = ExtractiveQAPipeline(retriever=retriever, reader=reader)
  File "/home/ec2-user/.local/lib/python3.7/site-packages/haystack/pipelines/standard_pipelines.py", line 208, in __init__
    self.pipeline.add_node(component=reader, name="Reader", inputs=["Retriever"])
  File "/home/ec2-user/.local/lib/python3.7/site-packages/haystack/pipelines/base.py", line 377, in add_node
    component_definitions = get_component_definitions(pipeline_config=self.get_config())
  File "/home/ec2-user/.local/lib/python3.7/site-packages/haystack/pipelines/config.py", line 70, in get_component_definitions
    raw_component_definitions = copy.deepcopy(pipeline_config["components"])
  File "/usr/lib64/python3.7/copy.py", line 150, in deepcopy
    y = copier(x, memo)
  File "/usr/lib64/python3.7/copy.py", line 216, in _deepcopy_list
    append(deepcopy(a, memo))
  File "/usr/lib64/python3.7/copy.py", line 150, in deepcopy
    y = copier(x, memo)
  File "/usr/lib64/python3.7/copy.py", line 241, in _deepcopy_dict
    y[deepcopy(key, memo)] = deepcopy(value, memo)
  File "/usr/lib64/python3.7/copy.py", line 150, in deepcopy
    y = copier(x, memo)
  File "/usr/lib64/python3.7/copy.py", line 241, in _deepcopy_dict
    y[deepcopy(key, memo)] = deepcopy(value, memo)
  File "/usr/lib64/python3.7/copy.py", line 180, in deepcopy
    y = _reconstruct(x, memo, *rv)
  File "/usr/lib64/python3.7/copy.py", line 281, in _reconstruct
    state = deepcopy(state, memo)
  File "/usr/lib64/python3.7/copy.py", line 150, in deepcopy
    y = copier(x, memo)
  File "/usr/lib64/python3.7/copy.py", line 241, in _deepcopy_dict
    y[deepcopy(key, memo)] = deepcopy(value, memo)
  File "/usr/lib64/python3.7/copy.py", line 180, in deepcopy
    y = _reconstruct(x, memo, *rv)
  File "/usr/lib64/python3.7/copy.py", line 281, in _reconstruct
    state = deepcopy(state, memo)
  File "/usr/lib64/python3.7/copy.py", line 150, in deepcopy
    y = copier(x, memo)
  File "/usr/lib64/python3.7/copy.py", line 241, in _deepcopy_dict
    y[deepcopy(key, memo)] = deepcopy(value, memo)
  File "/usr/lib64/python3.7/copy.py", line 150, in deepcopy
    y = copier(x, memo)
  File "/usr/lib64/python3.7/copy.py", line 248, in _deepcopy_method
    return type(x)(x.__func__, deepcopy(x.__self__, memo))
  File "/usr/lib64/python3.7/copy.py", line 180, in deepcopy
    y = _reconstruct(x, memo, *rv)
  File "/usr/lib64/python3.7/copy.py", line 281, in _reconstruct
    state = deepcopy(state, memo)
  File "/usr/lib64/python3.7/copy.py", line 150, in deepcopy
    y = copier(x, memo)
  File "/usr/lib64/python3.7/copy.py", line 241, in _deepcopy_dict
    y[deepcopy(key, memo)] = deepcopy(value, memo)
  File "/usr/lib64/python3.7/copy.py", line 180, in deepcopy
    y = _reconstruct(x, memo, *rv)
  File "/usr/lib64/python3.7/copy.py", line 281, in _reconstruct
    state = deepcopy(state, memo)
  File "/usr/lib64/python3.7/copy.py", line 150, in deepcopy
    y = copier(x, memo)
  File "/usr/lib64/python3.7/copy.py", line 241, in _deepcopy_dict
    y[deepcopy(key, memo)] = deepcopy(value, memo)
  File "/usr/lib64/python3.7/copy.py", line 180, in deepcopy
    y = _reconstruct(x, memo, *rv)
  File "/usr/lib64/python3.7/copy.py", line 281, in _reconstruct
    state = deepcopy(state, memo)
  File "/usr/lib64/python3.7/copy.py", line 150, in deepcopy
    y = copier(x, memo)
  File "/usr/lib64/python3.7/copy.py", line 241, in _deepcopy_dict
    y[deepcopy(key, memo)] = deepcopy(value, memo)
  File "/usr/lib64/python3.7/copy.py", line 150, in deepcopy
    y = copier(x, memo)
  File "/usr/lib64/python3.7/copy.py", line 241, in _deepcopy_dict
    y[deepcopy(key, memo)] = deepcopy(value, memo)
  File "/usr/lib64/python3.7/copy.py", line 169, in deepcopy
    rv = reductor(4)
TypeError: can't pickle SSLContext objects
```



Discussion:
Hello @FabianHertwig, thanks a ton for this bug report and the in-depth analysis. This is a pretty deep bug on a recently refactored area of the code: we really did not have non-copiable objects in mind while refactoring it.

I had a look at where the deepcopy is attempted and I see there _might_ be a way around it, so no hope is lost. In the meantime your "hacky" solution is actually the cleanest I can recommend until a fix is out, so I'm happy you found it already :smile: 

I'll let you know when a fix is out!

Resolving patch:
diff --git a/haystack/pipelines/config.py b/haystack/pipelines/config.py
--- a/haystack/pipelines/config.py
+++ b/haystack/pipelines/config.py
@@ -2,10 +2,10 @@
 
 import re
 import os
-import copy
 import json
 import logging
 from pathlib import Path
+from copy import copy
 
 import yaml
 import networkx as nx
@@ -67,13 +67,22 @@ def get_component_definitions(
                                          `_` sign must be used to specify nested hierarchical properties.
     """
     component_definitions = {}  # definitions of each component from the YAML.
-    raw_component_definitions = copy.deepcopy(pipeline_config["components"])
-    for component_definition in raw_component_definitions:
-        if overwrite_with_env_variables:
-            _overwrite_with_env_variables(component_definition)
-        name = component_definition.pop("name")
+
+    for raw_component_definition in pipeline_config["components"]:
+        name = raw_component_definition["name"]
+        # We perform a shallow copy here because of https://github.com/deepset-ai/haystack/issues/2568
+        component_definition = {key: copy(value) for key, value in raw_component_definition.items() if key != "name"}
         component_definitions[name] = component_definition
 
+        if overwrite_with_env_variables:
+            for key, value in os.environ.items():
+                env_prefix = f"{name}_params_".upper()
+                if key.startswith(env_prefix):
+                    param_name = key.replace(env_prefix, "").lower()
+                    component_definition["params"][param_name] = value
+                    logger.info(
+                        f"Param '{param_name}' of component '{name}' overwritten with environment variable '{key}' value '{value}'."
+                    )
     return component_definitions
 
 
@@ -489,21 +498,3 @@ def _get_defined_node_class(node_name: str, components: Dict[str, Dict[str, str]
         ) from e
 
     return node_class
-
-
-def _overwrite_with_env_variables(component_definition: Dict[str, Any]):
-    """
-    Overwrite the pipeline config with environment variables. For example, to change index name param for an
-    ElasticsearchDocumentStore, an env variable 'MYDOCSTORE_PARAMS_INDEX=documents-2021' can be set. Note that an
-    `_` sign must be used to specify nested hierarchical properties.
-
-    :param definition: a dictionary containing the YAML definition of a component.
-    """
-    env_prefix = f"{component_definition['name']}_params_".upper()
-    for key, value in os.environ.items():
-        if key.startswith(env_prefix):
-            param_name = key.replace(env_prefix, "").lower()
-            component_definition["params"][param_name] = value
-            logger.info(
-                f"Param '{param_name}' of component '{component_definition['name']}' overwritten with environment variable '{key}' value '{value}'."
-            )


Test patch:
diff --git a/test/pipelines/test_pipeline.py b/test/pipelines/test_pipeline.py
--- a/test/pipelines/test_pipeline.py
+++ b/test/pipelines/test_pipeline.py
@@ -1,14 +1,18 @@
+from copy import deepcopy
 from pathlib import Path
 import os
+import ssl
 import json
 import platform
 import sys
 from typing import Tuple
+from pyparsing import original_text_for
 
 import pytest
 from requests import PreparedRequest
 import responses
 import logging
+from transformers import pipeline
 import yaml
 import pandas as pd
 
@@ -19,7 +23,7 @@
 from haystack.nodes.base import BaseComponent
 from haystack.nodes.retriever.sparse import BM25Retriever
 from haystack.pipelines import Pipeline, RootNode
-from haystack.pipelines.config import validate_config_strings
+from haystack.pipelines.config import validate_config_strings, get_component_definitions
 from haystack.pipelines.utils import generate_code
 from haystack.errors import PipelineConfigError
 from haystack.nodes import PreProcessor, TextConverter
@@ -1424,6 +1428,75 @@ def test_graph_validation_duplicate_node():
         pipeline.add_node(name="node", component=other_node, inputs=["Query"])
 
 
+# See https://github.com/deepset-ai/haystack/issues/2568
+def test_pipeline_nodes_can_have_uncopiable_objects_as_args():
+    class DummyNode(MockNode):
+        def __init__(self, uncopiable: ssl.SSLContext):
+            self.uncopiable = uncopiable
+
+    node = DummyNode(uncopiable=ssl.SSLContext())
+    pipeline = Pipeline()
+    pipeline.add_node(component=node, name="node", inputs=["Query"])
+
+    # If the object is getting copied, it will raise TypeError: cannot pickle 'SSLContext' object
+    # `get_components_definitions()` should NOT copy objects to allow this usecase
+    get_component_definitions(pipeline.get_config())
+
+
+def test_pipeline_env_vars_do_not_modify__component_config(monkeypatch):
+    class DummyNode(MockNode):
+        def __init__(self, replaceable: str):
+            self.replaceable = replaceable
+
+    monkeypatch.setenv("NODE_PARAMS_REPLACEABLE", "env value")
+
+    node = DummyNode(replaceable="init value")
+    pipeline = Pipeline()
+    pipeline.add_node(component=node, name="node", inputs=["Query"])
+
+    original_component_config = deepcopy(node._component_config)
+    original_pipeline_config = deepcopy(pipeline.get_config())
+
+    no_env_defs = get_component_definitions(pipeline.get_config(), overwrite_with_env_variables=False)
+    env_defs = get_component_definitions(pipeline.get_config(), overwrite_with_env_variables=True)
+
+    new_component_config = deepcopy(node._component_config)
+    new_pipeline_config = deepcopy(pipeline.get_config())
+
+    assert no_env_defs != env_defs
+    assert no_env_defs["node"]["params"]["replaceable"] == "init value"
+    assert env_defs["node"]["params"]["replaceable"] == "env value"
+
+    assert original_component_config == new_component_config
+    assert original_component_config["params"]["replaceable"] == "init value"
+    assert new_component_config["params"]["replaceable"] == "init value"
+
+    assert original_pipeline_config == new_pipeline_config
+    assert original_pipeline_config["components"][0]["params"]["replaceable"] == "init value"
+    assert new_pipeline_config["components"][0]["params"]["replaceable"] == "init value"
+
+
+def test_pipeline_env_vars_do_not_modify_pipeline_config(monkeypatch):
+    class DummyNode(MockNode):
+        def __init__(self, replaceable: str):
+            self.replaceable = replaceable
+
+    monkeypatch.setenv("NODE_PARAMS_REPLACEABLE", "env value")
+
+    node = DummyNode(replaceable="init value")
+    pipeline = Pipeline()
+    pipeline.add_node(component=node, name="node", inputs=["Query"])
+
+    pipeline_config = pipeline.get_config()
+    original_pipeline_config = deepcopy(pipeline_config)
+
+    get_component_definitions(pipeline_config, overwrite_with_env_variables=True)
+
+    assert original_pipeline_config == pipeline_config
+    assert original_pipeline_config["components"][0]["params"]["replaceable"] == "init value"
+    assert pipeline_config["components"][0]["params"]["replaceable"] == "init value"
+
+
 def test_parallel_paths_in_pipeline_graph():
     class A(RootNode):
         def run(self):