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

wenjin272 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/flink-agents.git


The following commit(s) were added to refs/heads/main by this push:
     new 806a7ff23 [Tech Debt][runtime][python] Preserve skill-repository close 
failures in SkillManager.close() (#1221)
806a7ff23 is described below

commit 806a7ff23c263e86676c07513460a759c63b4cda
Author: william <[email protected]>
AuthorDate: Mon Oct 12 11:25:41 2026 +0800

    [Tech Debt][runtime][python] Preserve skill-repository close failures in 
SkillManager.close() (#1221)
---
 python/flink_agents/runtime/close_utils.py         |  88 +++++++++++
 .../flink_agents/runtime/flink_runner_context.py   |  17 +-
 python/flink_agents/runtime/resource_cache.py      |  43 +----
 python/flink_agents/runtime/skill/skill_manager.py |  38 ++++-
 .../runtime/skill/tests/test_manager_close.py      | 176 +++++++++++++++++++++
 5 files changed, 309 insertions(+), 53 deletions(-)

diff --git a/python/flink_agents/runtime/close_utils.py 
b/python/flink_agents/runtime/close_utils.py
new file mode 100644
index 000000000..360124169
--- /dev/null
+++ b/python/flink_agents/runtime/close_utils.py
@@ -0,0 +1,88 @@
+################################################################################
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+# limitations under the License.
+#################################################################################
+"""Helpers for "close everything, then surface the first failure" cleanup 
paths.
+
+Shared by the runtime components whose ``close()`` must keep going after a
+failure: ``ResourceCache``, ``FlinkRunnerContext`` and ``SkillManager``. One
+implementation is what keeps those three contracts identical rather than merely
+similar.
+
+This module deliberately imports nothing from the project: ``skill_manager``
+cannot reach a helper parked in ``resource_cache``, because
+``skill_manager -> resource_cache -> resource_context -> skill_manager`` is an
+import cycle.
+"""
+
+import logging
+from collections.abc import Callable
+
+_LOG = logging.getLogger(__name__)
+
+
+def failure_of(close: Callable[[], None]) -> Exception | None:
+    """Run ``close``, returning any failure instead of raising it.
+
+    Keeps the caller's cleanup loop free of a ``try`` block so one bad 
component
+    cannot end the iteration.
+
+    Parameters
+    ----------
+    close : Callable[[], None]
+        Cleanup callback to run.
+
+    Returns:
+    -------
+    Exception | None
+        The failure raised by ``close``, or ``None`` when it succeeded.
+    """
+    try:
+        close()
+    except Exception as e:
+        return e
+    return None
+
+
+def first_or_logged(
+    failure: Exception | None, previous: Exception | None, what: str
+) -> Exception | None:
+    """Keep the first failure and log any later one.
+
+    The Python analogue of Flink's ``ExceptionUtils.firstOrSuppressed``. Later
+    failures are logged rather than attached, because ``ExceptionGroup`` 
requires
+    3.11 and this package supports 3.10.
+
+    Parameters
+    ----------
+    failure : Exception | None
+        Failure produced by the component just closed, if any.
+    previous : Exception | None
+        First failure seen so far, if any.
+    what : str
+        Short description of the component, used in the log message.
+
+    Returns:
+    -------
+    Exception | None
+        The failure to keep: the first one seen.
+    """
+    if failure is None:
+        return previous
+    if previous is None:
+        return failure
+    _LOG.warning("Suppressed failure closing %s.", what, exc_info=failure)
+    return previous
diff --git a/python/flink_agents/runtime/flink_runner_context.py 
b/python/flink_agents/runtime/flink_runner_context.py
index bdd37b776..43b6be163 100644
--- a/python/flink_agents/runtime/flink_runner_context.py
+++ b/python/flink_agents/runtime/flink_runner_context.py
@@ -46,6 +46,7 @@ from flink_agents.api.runner_context import (
     RunnerContext,
 )
 from flink_agents.api.trace import ExecutionReporter
+from flink_agents.runtime.close_utils import failure_of, first_or_logged
 from flink_agents.runtime.durable_exception import (
     deserialize_durable_exception,
     durable_exception_message,
@@ -66,11 +67,7 @@ from 
flink_agents.runtime.memory.internal_base_long_term_memory import (
 from flink_agents.runtime.memory.mem0.mem0_long_term_memory import (
     Mem0LongTermMemory,
 )
-from flink_agents.runtime.resource_cache import (
-    ResourceCache,
-    _failure_of,
-    _first_or_logged,
-)
+from flink_agents.runtime.resource_cache import ResourceCache
 from flink_agents.runtime.task_lifecycle_listener import TaskLifecycleListener
 
 logger = logging.getLogger(__name__)
@@ -1720,12 +1717,12 @@ class FlinkRunnerContext(RunnerContext, 
ExecutionReporter):
         ltm = self.__ltm
         self.__ltm = None
 
-        first_failure = _failure_of(ltm.close) if ltm is not None else None
+        first_failure = failure_of(ltm.close) if ltm is not None else None
 
         resource_cache = self.__resource_cache
         self.__resource_cache = None
-        first_failure = _first_or_logged(
-            _failure_of(resource_cache.close) if resource_cache is not None 
else None,
+        first_failure = first_or_logged(
+            failure_of(resource_cache.close) if resource_cache is not None 
else None,
             first_failure,
             "runner context resource cache",
         )
@@ -1737,8 +1734,8 @@ class FlinkRunnerContext(RunnerContext, 
ExecutionReporter):
         scoped_caches = self.__scoped_resource_caches
         self.__scoped_resource_caches = {}
         for scoped in scoped_caches.values():
-            first_failure = _first_or_logged(
-                _failure_of(scoped[1].close),
+            first_failure = first_or_logged(
+                failure_of(scoped[1].close),
                 first_failure,
                 "sub-agent scope resource cache",
             )
diff --git a/python/flink_agents/runtime/resource_cache.py 
b/python/flink_agents/runtime/resource_cache.py
index ace542c24..1e0db4ecd 100644
--- a/python/flink_agents/runtime/resource_cache.py
+++ b/python/flink_agents/runtime/resource_cache.py
@@ -15,8 +15,6 @@
 #  See the License for the specific language governing permissions and
 # limitations under the License.
 
#################################################################################
-import logging
-from collections.abc import Callable
 from typing import Any, Dict
 
 from flink_agents.api.resource import Resource, ResourceType
@@ -24,40 +22,9 @@ from flink_agents.plan.configuration import 
AgentConfiguration
 from flink_agents.plan.function import JavaFunction
 from flink_agents.plan.resource_provider import JavaResourceProvider, 
ResourceProvider
 from flink_agents.plan.tools.function_tool import FunctionTool
+from flink_agents.runtime.close_utils import failure_of, first_or_logged
 from flink_agents.runtime.resource_context import ResourceContextImpl
 
-_LOG = logging.getLogger(__name__)
-
-
-def _failure_of(close: Callable[[], None]) -> Exception | None:
-    """Run ``close``, returning any failure instead of raising it.
-
-    Keeps the caller's cleanup loop free of a ``try`` block so one bad 
component
-    cannot end the iteration.
-    """
-    try:
-        close()
-    except Exception as e:
-        return e
-    return None
-
-
-def _first_or_logged(
-    failure: Exception | None, previous: Exception | None, what: str
-) -> Exception | None:
-    """Keep the first failure and log any later one.
-
-    The Python analogue of Flink's ``ExceptionUtils.firstOrSuppressed``. Later
-    failures are logged rather than attached, because ``ExceptionGroup`` 
requires
-    3.11 and this package supports 3.10.
-    """
-    if failure is None:
-        return previous
-    if previous is None:
-        return failure
-    _LOG.warning("Suppressed failure closing %s.", what, exc_info=failure)
-    return previous
-
 
 class ResourceCache:
     """Lazily resolves and caches Resource instances from ResourceProviders.
@@ -166,12 +133,12 @@ class ResourceCache:
         first_failure: Exception | None = None
         for typed in self._cache.values():
             for resource in typed.values():
-                first_failure = _first_or_logged(
-                    _failure_of(resource.close), first_failure, "resource"
+                first_failure = first_or_logged(
+                    failure_of(resource.close), first_failure, "resource"
                 )
         self._cache.clear()
-        first_failure = _first_or_logged(
-            _failure_of(self._resource_context.close), first_failure, 
"resource context"
+        first_failure = first_or_logged(
+            failure_of(self._resource_context.close), first_failure, "resource 
context"
         )
         if first_failure is not None:
             raise first_failure
diff --git a/python/flink_agents/runtime/skill/skill_manager.py 
b/python/flink_agents/runtime/skill/skill_manager.py
index 540d37065..c02a26499 100644
--- a/python/flink_agents/runtime/skill/skill_manager.py
+++ b/python/flink_agents/runtime/skill/skill_manager.py
@@ -15,12 +15,12 @@
 #  See the License for the specific language governing permissions and
 # limitations under the License.
 
#################################################################################
-import contextlib
 import logging
 from pathlib import Path
 from typing import TYPE_CHECKING, Dict, List, Mapping
 
 from flink_agents.api.skills import Skills, SkillSourceSpec, redact_skill_url
+from flink_agents.runtime.close_utils import failure_of, first_or_logged
 from flink_agents.runtime.skill.agent_skill import AgentSkill, SkillOrigin
 from flink_agents.runtime.skill.repository.classpath_repository import (
     ClasspathSkillRepository,
@@ -203,7 +203,17 @@ class SkillManager:
             # SkillManager reference to clean them up via close() itself, so
             # without this their temp dirs / atexit handlers leak until
             # interpreter exit.
-            self.close()
+            try:
+                self.close()
+            except Exception as close_failure:
+                # close() surfaces repository failures now, so it can raise 
here
+                # and would otherwise replace the load failure this cleanup is
+                # running for. Java attaches it with addSuppressed; 
ExceptionGroup
+                # needs Python 3.11 and this package supports 3.10, so log it.
+                logger.warning(
+                    "Suppressed failure closing skill repositories after a 
failed load.",
+                    exc_info=close_failure,
+                )
             raise
 
     def _register_repo(self, repo: "SkillRepository", origin: SkillOrigin) -> 
None:
@@ -225,20 +235,38 @@ class SkillManager:
 
     def close(self) -> None:
         """Close every opened :class:`SkillRepository`, releasing any temp 
directory
-        materialized for URL / classpath-zip / package sources. Idempotent.
+        materialized for URL / classpath-zip / package sources.
 
         Iterates ``_opened_repos`` rather than ``_repos.values()``: duplicate 
skill
         names overwrite the earlier repo's reference in ``_repos``, but the 
displaced
         repo is still owned and must be closed. Dedup by identity in case the 
same
         repo contributes multiple skills.
+
+        Mirrors the Java ``SkillManager.closeRepos()`` contract from #987 and 
the
+        Python ``ResourceCache.close()``: every repository is attempted even 
when an
+        earlier one fails, and the first failure is re-raised after the loop, 
so a
+        real shutdown bug (locked file, permission denied, disk full) reaches 
the
+        caller instead of being swallowed. Later failures are logged rather 
than
+        attached, because ``ExceptionGroup`` needs Python 3.11 and this package
+        supports 3.10.
+
+        Idempotent to the extent repository ``close()`` implementations are: 
one
+        that fails does so again on a second call, exactly as in Java.
+
+        Only ``Exception`` is caught, matching ``ResourceCache.close()``: 
cleanup
+        must not swallow ``KeyboardInterrupt`` or ``SystemExit``.
         """
         seen: set[int] = set()
+        first_failure: Exception | None = None
         for repo in self._opened_repos:
             if id(repo) in seen:
                 continue
             seen.add(id(repo))
-            with contextlib.suppress(Exception):
-                repo.close()
+            first_failure = first_or_logged(
+                failure_of(repo.close), first_failure, "skill repository"
+            )
+        if first_failure is not None:
+            raise first_failure
 
     def __enter__(self) -> "SkillManager":
         return self
diff --git a/python/flink_agents/runtime/skill/tests/test_manager_close.py 
b/python/flink_agents/runtime/skill/tests/test_manager_close.py
new file mode 100644
index 000000000..20cebdf4f
--- /dev/null
+++ b/python/flink_agents/runtime/skill/tests/test_manager_close.py
@@ -0,0 +1,176 @@
+################################################################################
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+# limitations under the License.
+#################################################################################
+"""Close-path contract tests for the Python SkillManager.
+
+These mirror the Java ``SkillManagerTest`` close tests and the Python
+``ResourceCache`` close tests: a failing repository must not strand the
+repositories behind it, and the first failure must reach the caller unchanged
+rather than being swallowed.
+"""
+
+import pytest
+
+from flink_agents.api.skills import Skills, SkillSourceSpec
+from flink_agents.runtime.skill.agent_skill import AgentSkill
+from flink_agents.runtime.skill.skill_manager import SkillManager
+from flink_agents.runtime.skill.skill_repository import SkillRepository
+from flink_agents.runtime.skill.skill_source_handler import SkillSourceHandler
+
+
+class FakeRepo(SkillRepository):
+    """Records whether ``close()`` ran, and optionally fails it."""
+
+    def __init__(self, skill_name: str, failure: Exception | None = None) -> 
None:
+        """Create a repository exposing one skill, optionally failing on 
close."""
+        self._skill_name = skill_name
+        self._failure = failure
+        self.closed = False
+
+    def get_skill(self, name: str) -> AgentSkill | None:
+        """Return the single skill when it is the one asked for."""
+        return self.get_skills()[0] if name == self._skill_name else None
+
+    def get_skills(self) -> list[AgentSkill]:
+        """Return the single skill held by this repository."""
+        return [AgentSkill(name=self._skill_name, description="dummy", 
content="body")]
+
+    def get_resources(self, name: str) -> dict[str, str]:
+        """Return no resources for any skill."""
+        return {}
+
+    def close(self) -> None:
+        """Record the call, then fail if this repository was configured to."""
+        self.closed = True
+        if self._failure is not None:
+            raise self._failure
+
+
+def _manager_with(monkeypatch: pytest.MonkeyPatch, *repos: FakeRepo) -> 
SkillManager:
+    """Build a SkillManager whose sources open exactly ``repos``, in order."""
+    opened = list(repos)
+
+    def opener(params) -> SkillRepository:
+        return opened.pop(0)
+
+    monkeypatch.setattr(
+        SkillManager,
+        "_create_handlers",
+        lambda self, bridge: {"test-close": SkillSourceHandler(opener)},
+    )
+    config = Skills(
+        sources=[SkillSourceSpec(scheme="test-close", params={}) for _ in 
repos]
+    )
+    return SkillManager(config)
+
+
+def test_close_attempts_every_repo_and_rethrows_first_failure(monkeypatch) -> 
None:
+    """A failing repository must not strand the ones behind it.
+
+    The failure reaches the caller unchanged in identity, not wrapped, which is
+    what makes it usable by the caller that owns the shutdown.
+    """
+    failure = RuntimeError("repo close failed")
+    failing = FakeRepo("first", failure=failure)
+    surviving = FakeRepo("second")
+    manager = _manager_with(monkeypatch, failing, surviving)
+
+    with pytest.raises(RuntimeError, match="repo close failed") as excinfo:
+        manager.close()
+
+    assert excinfo.value is failure
+    assert failing.closed
+    assert surviving.closed, "a failing repo must not strand the ones behind 
it"
+
+
+def test_close_reports_first_failure_when_several_fail(monkeypatch) -> None:
+    """The first failure is the one raised; later ones do not replace it."""
+    first = RuntimeError("first")
+    second = RuntimeError("second")
+    manager = _manager_with(
+        monkeypatch, FakeRepo("a", failure=first), FakeRepo("b", 
failure=second)
+    )
+
+    with pytest.raises(RuntimeError, match="first") as excinfo:
+        manager.close()
+
+    assert excinfo.value is first
+
+
+def test_close_logs_later_failures(
+    monkeypatch, caplog: pytest.LogCaptureFixture
+) -> None:
+    """Later failures are logged, since Python 3.10 cannot attach them."""
+    manager = _manager_with(
+        monkeypatch,
+        FakeRepo("a", failure=RuntimeError("first")),
+        FakeRepo("b", failure=RuntimeError("second")),
+    )
+
+    with pytest.raises(RuntimeError, match="first"):
+        manager.close()
+
+    assert "Suppressed failure closing skill repository." in caplog.text
+    assert "second" in caplog.text
+
+
+def test_close_returns_normally_when_nothing_fails(monkeypatch) -> None:
+    """The healthy path stays a plain no-raise close."""
+    surviving = FakeRepo("only")
+    manager = _manager_with(monkeypatch, surviving)
+
+    manager.close()
+
+    assert surviving.closed
+
+
+def test_load_failure_is_not_replaced_by_close_failure(
+    monkeypatch, caplog: pytest.LogCaptureFixture
+) -> None:
+    """A close failure during the failed-load cleanup is logged, not re-raised.
+
+    The load failure is what the caller needs; letting the cleanup failure
+    replace it would hide the reason the manager could not be built at all.
+    """
+    failing = FakeRepo("skill-1", failure=RuntimeError("close boom"))
+    counter = {"n": 0}
+
+    def opener(params) -> SkillRepository:
+        counter["n"] += 1
+        if counter["n"] == 2:
+            msg = "open boom"
+            raise OSError(msg)
+        return failing
+
+    monkeypatch.setattr(
+        SkillManager,
+        "_create_handlers",
+        lambda self, bridge: {"test-load": SkillSourceHandler(opener)},
+    )
+    config = Skills(
+        sources=[
+            SkillSourceSpec(scheme="test-load", params={}),
+            SkillSourceSpec(scheme="test-load", params={}),
+        ]
+    )
+
+    with pytest.raises(RuntimeError, match="Failed to load skills"):
+        SkillManager(config)
+
+    assert failing.closed
+    assert "Suppressed failure closing skill repositories" in caplog.text
+    assert "close boom" in caplog.text

Reply via email to