This is an automated email from the ASF dual-hosted git repository.

potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 65a83820fef Let SandboxToolset be deep-copied again (#74525)
65a83820fef is described below

commit 65a83820fefdf693153c2f1016af4daa6c1fbe61
Author: PoAn Yang <[email protected]>
AuthorDate: Sat Oct 10 16:04:54 2026 +0800

    Let SandboxToolset be deep-copied again (#74525)
    
    * Let SandboxToolset be deep-copied again
    
    Signed-off-by: PoAn Yang <[email protected]>
    
    * Describe what the shared sandbox create lock guards
    
    The lock is process-wide and also protects close and the open re-check, 
which the previous comment did not say.
    
    Generated-by: Claude Opus 5
    Claude-Session: https://claude.ai/code/session_014np1fVAxHmGaBFt3qebJ8x
    
    ---------
    
    Signed-off-by: PoAn Yang <[email protected]>
    Co-authored-by: Jarek Potiuk <[email protected]>
---
 .../providers/common/ai/toolsets/sandbox.py        | 16 ++++++++++-----
 .../tests/unit/common/ai/toolsets/test_sandbox.py  | 24 +++++++++++++++++++++-
 2 files changed, 34 insertions(+), 6 deletions(-)

diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/toolsets/sandbox.py 
b/providers/common/ai/src/airflow/providers/common/ai/toolsets/sandbox.py
index 2b4561c4098..a03c968b390 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/toolsets/sandbox.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/toolsets/sandbox.py
@@ -74,6 +74,13 @@ _RELEASE_RETRY_DELAY = 1.0
 
 # Runs backend.create off the event loop. Its threads start on first use, not 
at import.
 _provisioning = 
concurrent.futures.ThreadPoolExecutor(thread_name_prefix="sandbox-create")
+# One process-wide lock for every SandboxToolset. It guards each toolset's 
pending creation,
+# the ``_open`` flag that ``_close`` clears, and the ``_open`` re-check before 
a newly created
+# sandbox is published. Airflow deep-copies tasks and default_args together 
with the toolsets
+# they hold, and a lock cannot be deep-copied, so toolsets share this lock 
instead of holding
+# their own. It is never held during a backend call, so one toolset never 
waits for another
+# toolset's sandbox.
+_create_lock = threading.Lock()
 
 RUN_COMMAND = "run_command"
 
@@ -350,7 +357,6 @@ class SandboxToolset(AirflowToolset):
         self._max_export_bytes = int(max_export_bytes)
         self._tool_prefix = tool_prefix
         self._sandbox: str | None = None
-        self._create_lock = threading.Lock()
         self._create_future: concurrent.futures.Future[str] | None = None
         # Set while attached: who this run claimed the sandbox as, when the 
sandbox ends
         # on this process's clock (None when the creator recorded no 
lifetime), and what
@@ -581,7 +587,7 @@ class SandboxToolset(AirflowToolset):
         return None
 
     def _close(self, *, run_failed: bool) -> None:
-        with self._create_lock:
+        with _create_lock:
             self._open = False
             pending = self._create_future
         sandbox = self._sandbox
@@ -747,7 +753,7 @@ class SandboxToolset(AirflowToolset):
         # A native framework can make the first calls from several threads, 
each with an
         # event loop of its own, so the one creation they share is a 
thread-safe future
         # that any loop can await, not a task bound to the loop that started 
it.
-        with self._create_lock:
+        with _create_lock:
             # Checked again under the lock: another thread may have finished 
creating it.
             if self._sandbox is not None:
                 return self._sandbox
@@ -775,14 +781,14 @@ class SandboxToolset(AirflowToolset):
                 f"Could not provision a sandbox on backend 
{self._backend.name!r}: {e}"
             ) from e
         else:
-            with self._create_lock:
+            with _create_lock:
                 if not self._open:
                     # The block ended while this call waited; closing destroys 
the sandbox.
                     raise SandboxTerminalError("The sandbox was closed while 
it was being provisioned.")
                 self._sandbox = sandbox
             return sandbox
         finally:
-            with self._create_lock:
+            with _create_lock:
                 if self._create_future is create_future:
                     self._create_future = None
 
diff --git a/providers/common/ai/tests/unit/common/ai/toolsets/test_sandbox.py 
b/providers/common/ai/tests/unit/common/ai/toolsets/test_sandbox.py
index 4023628b0dd..4c291cd2f76 100644
--- a/providers/common/ai/tests/unit/common/ai/toolsets/test_sandbox.py
+++ b/providers/common/ai/tests/unit/common/ai/toolsets/test_sandbox.py
@@ -17,6 +17,7 @@
 from __future__ import annotations
 
 import asyncio
+import copy
 import re
 import threading
 import time
@@ -50,7 +51,7 @@ from airflow.providers.common.ai.sandbox.base import (
 from airflow.providers.common.ai.tools import ToolCallError
 from airflow.providers.common.ai.toolsets import sandbox as sandbox_module
 from airflow.providers.common.ai.toolsets.sandbox import SandboxToolset
-from airflow.providers.common.compat.sdk import ObjectStoragePath
+from airflow.providers.common.compat.sdk import DAG, ObjectStoragePath, 
TaskGroup
 
 from unit.common.ai.sandbox.fake_tags import InMemoryTagStore
 
@@ -798,6 +799,27 @@ class TestForRun:
         assert isinstance(forked, CustomToolset)
 
 
+class TestDeepCopy:
+    """Airflow deep-copies tasks and default_args together with the toolsets 
they hold."""
+
+    @pytest.mark.asyncio
+    async def test_a_deep_copied_toolset_runs_its_tools(self):
+        copied = copy.deepcopy(SandboxToolset(_RecordingBackend()))
+
+        async with copied:
+            result = await _call(copied, "run_command", {"command": "ls"})
+
+        assert result == "[stdout]\nout"
+
+    def test_task_group_default_args_can_hold_a_sandbox_toolset(self):
+        with DAG(dag_id="sandbox_in_task_group", schedule=None):
+            group = TaskGroup(
+                group_id="agents", default_args={"toolsets": 
[SandboxToolset(_RecordingBackend())]}
+            )
+
+        assert [type(toolset) for toolset in group.default_args["toolsets"]] 
== [SandboxToolset]
+
+
 class TestExports:
     @pytest.mark.parametrize(
         ("kwargs", "match"),

Reply via email to