fix: fixed concurrent tool usage error (#12548)

* Fixed concurrent tool usage error

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes (attempt 2/3)

* Added test

* added ruff exception noqa

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Lucas Oliveira
2026-04-08 16:04:12 -03:00
committed by GitHub
parent aa40af2468
commit 749865b314
2 changed files with 97 additions and 4 deletions

View File

@ -382,12 +382,34 @@ class Component(CustomComponent):
return memo[id(self)]
# Shallow-copy config/inputs: they may contain non-picklable services
# (e.g. _tracing_service holds ServiceManager with threading.RLock).
kwargs = dict(self.__config)
kwargs["inputs"] = dict(self.__inputs)
# use the mangled names to access the private attributes
config = getattr(self, "_Component__config", {})
inputs_raw = getattr(self, "_Component__inputs", {})
kwargs = dict(config)
kwargs["inputs"] = dict(inputs_raw)
new_component = type(self)(**kwargs)
new_component._code = self._code
new_component._outputs_map = self._outputs_map
new_component._inputs = deepcopy(self._inputs, memo)
# Safe deepcopy of inputs
new_inputs = {}
for k, v in self._inputs.items():
try:
# Attempt to deepcopy the entire input object
new_inputs[k] = deepcopy(v, memo)
except Exception: # noqa: BLE001
# If deepcopy fails (e.g. due to RLock), handle the value carefully
# Pydantic's model_copy(deep=False) creates a shallow copy
input_copy = v.model_copy()
try:
input_copy.value = deepcopy(v.value, memo)
except Exception: # noqa: BLE001
# Keep the original value (shallow copy) if it can't be deepcopied
input_copy.value = v.value
new_inputs[k] = input_copy
new_component._inputs = new_inputs
new_component._edges = self._edges
new_component._components = self._components
new_component._parameters = dict(self._parameters)

View File

@ -11,7 +11,7 @@ from copy import deepcopy
from lfx.base.tools.component_tool import ComponentToolkit
from lfx.custom.custom_component.component import Component
from lfx.inputs.inputs import MessageTextInput
from lfx.inputs.inputs import DataInput, MessageTextInput
from lfx.io import Output
from lfx.schema.data import Data
@ -149,6 +149,77 @@ def test_should_isolate_inputs_when_component_has_non_picklable_state():
assert product_ids == {"P1", "P2"}
def test_deepcopy_with_non_picklable_input_value():
"""Deepcopy must not fail when an input value is non-picklable.
Some components receive complex objects (e.g. LangChain models/clients)
in their inputs that hold threading.RLock instances.
"""
class MockComponentWithLockValue(Component):
inputs = [DataInput(name="input_with_lock")]
def build(self):
return "ok"
component = MockComponentWithLockValue()
lock_val = _FakeServiceWithLock()
component.set(input_with_lock=lock_val)
# Must not raise "cannot pickle '_thread.RLock' object"
clone = deepcopy(component)
assert clone is not component
# The non-picklable value should be shared (shallow-copied fallback)
assert clone.input_with_lock is lock_val
def test_should_isolate_inputs_when_input_has_non_picklable_value():
"""End-to-end: concurrent tool invocation must work even with non-picklable input values.
Verified that the tool isolation still works when one of the inputs
carries a non-picklable object (forcing the shallow-copy fallback in deepcopy).
"""
class SlowToolWithLock(SlowLabelComponent):
inputs = [
*SlowLabelComponent.inputs,
DataInput(name="lock_input", tool_mode=True),
]
# Arrange
lock_val = _FakeServiceWithLock()
component = SlowToolWithLock()
component.set(lock_input=lock_val) # Set the non-picklable value on the component
toolkit = ComponentToolkit(component=component)
tools = toolkit.get_tools()
tool = tools[0]
results = []
def invoke_tool(product_id: str, label: str) -> None:
# We don't pass lock_input here to avoid Pydantic validation of tool arguments
# The deepcopy(component) inside tool.invoke() will still encounter lock_val
result = tool.invoke({"product_id": product_id, "label": label})
results.append(result)
# Act
with ThreadPoolExecutor(max_workers=2) as executor:
future1 = executor.submit(invoke_tool, "P1", "L1")
future2 = executor.submit(invoke_tool, "P2", "L2")
future1.result()
future2.result()
# Assert
assert len(results) == 2
for result in results:
assert result["product_id_before"] == result["product_id_after"]
assert result["label_before"] == result["label_after"]
product_ids = {r["product_id_before"] for r in results}
assert product_ids == {"P1", "P2"}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------