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 403261d5 [runtime][python] Release bridge-owned Pemja objects (#944)
403261d5 is described below

commit 403261d56b3e8f81d3fd973c92e8c8c9cd037090
Author: Yu Tang <[email protected]>
AuthorDate: Mon Aug 31 14:50:56 2026 +0800

    [runtime][python] Release bridge-owned Pemja objects (#944)
    
    Co-Authored-By: Claude Code <[email protected]>
---
 .../flink_agents/runtime/flink_runner_context.py   |  27 +++--
 .../tests/test_flink_runner_context_close.py       |  89 +++++++++++++++
 .../agents/runtime/memory/Mem0LongTermMemory.java  |  13 ++-
 .../runtime/operator/PythonBridgeManager.java      |  18 ++--
 .../runtime/python/utils/PythonActionExecutor.java |  39 ++++---
 .../python/utils/PythonResourceAdapterImpl.java    |  12 ++-
 .../runtime/memory/Mem0LongTermMemoryTest.java     |  18 +++-
 .../runtime/operator/PythonBridgeManagerTest.java  |  46 ++++++++
 .../python/utils/PythonActionExecutorTest.java     | 119 +++++++++++++++++++++
 .../utils/PythonResourceAdapterImplTest.java       |  14 +++
 10 files changed, 362 insertions(+), 33 deletions(-)

diff --git a/python/flink_agents/runtime/flink_runner_context.py 
b/python/flink_agents/runtime/flink_runner_context.py
index 0d6e08a0..284b3c6e 100644
--- a/python/flink_agents/runtime/flink_runner_context.py
+++ b/python/flink_agents/runtime/flink_runner_context.py
@@ -60,7 +60,11 @@ 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
+from flink_agents.runtime.resource_cache import (
+    ResourceCache,
+    _failure_of,
+    _first_or_logged,
+)
 
 logger = logging.getLogger(__name__)
 
@@ -1210,14 +1214,21 @@ class FlinkRunnerContext(RunnerContext, 
ExecutionReporter):
 
     @override
     def close(self) -> None:
-        if self.long_term_memory is not None:
-            self.long_term_memory.close()
+        ltm = self.__ltm
+        self.__ltm = None
 
-        if self.__resource_cache is not None:
-            try:
-                self.__resource_cache.close()
-            finally:
-                self.__resource_cache = 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,
+            "runner context resource cache",
+        )
+
+        if first_failure is not None:
+            raise first_failure
 
 
 def create_flink_runner_context(
diff --git 
a/python/flink_agents/runtime/tests/test_flink_runner_context_close.py 
b/python/flink_agents/runtime/tests/test_flink_runner_context_close.py
new file mode 100644
index 00000000..24ee5572
--- /dev/null
+++ b/python/flink_agents/runtime/tests/test_flink_runner_context_close.py
@@ -0,0 +1,89 @@
+################################################################################
+#  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.
+#################################################################################
+from unittest.mock import MagicMock
+
+import pytest
+
+from flink_agents.runtime.flink_runner_context import FlinkRunnerContext
+
+
+def _create_context() -> tuple[FlinkRunnerContext, MagicMock, MagicMock]:
+    ctx = FlinkRunnerContext.__new__(FlinkRunnerContext)
+    ltm = MagicMock()
+    resource_cache = MagicMock()
+    ctx._FlinkRunnerContext__ltm = ltm
+    ctx._FlinkRunnerContext__resource_cache = resource_cache
+    return ctx, ltm, resource_cache
+
+
+def test_close_releases_long_term_memory_and_resource_cache_once() -> None:
+    ctx, ltm, resource_cache = _create_context()
+
+    ctx.close()
+    ctx.close()
+
+    ltm.close.assert_called_once_with()
+    resource_cache.close.assert_called_once_with()
+    assert ctx.long_term_memory is None
+
+
+def test_close_clears_long_term_memory_before_logical_cleanup() -> None:
+    ctx, ltm, resource_cache = _create_context()
+    ltm.close.side_effect = RuntimeError("logical close failed")
+
+    with pytest.raises(RuntimeError, match="logical close failed"):
+        ctx.close()
+
+    assert ctx.long_term_memory is None
+    resource_cache.close.assert_called_once_with()
+    ctx.close()
+    ltm.close.assert_called_once_with()
+    resource_cache.close.assert_called_once_with()
+
+
+def test_close_preserves_first_failure_when_both_cleanups_fail(
+    caplog: pytest.LogCaptureFixture,
+) -> None:
+    ctx, ltm, resource_cache = _create_context()
+    ltm_failure = RuntimeError("logical close failed")
+    resource_cache_failure = RuntimeError("resource cache close failed")
+    ltm.close.side_effect = ltm_failure
+    resource_cache.close.side_effect = resource_cache_failure
+
+    with pytest.raises(RuntimeError, match="logical close failed") as exc_info:
+        ctx.close()
+
+    assert exc_info.value is ltm_failure
+    assert "Suppressed failure closing runner context resource cache." in 
caplog.text
+    assert "resource cache close failed" in caplog.text
+
+    ctx.close()
+    ltm.close.assert_called_once_with()
+    resource_cache.close.assert_called_once_with()
+
+
+def test_close_does_not_demote_system_exit_behind_an_earlier_failure() -> None:
+    ctx, ltm, resource_cache = _create_context()
+    ltm.close.side_effect = RuntimeError("logical close failed")
+    system_exit = SystemExit("resource cache close interrupted")
+    resource_cache.close.side_effect = system_exit
+
+    with pytest.raises(SystemExit) as exc_info:
+        ctx.close()
+
+    assert exc_info.value is system_exit
diff --git 
a/runtime/src/main/java/org/apache/flink/agents/runtime/memory/Mem0LongTermMemory.java
 
b/runtime/src/main/java/org/apache/flink/agents/runtime/memory/Mem0LongTermMemory.java
index 11d95ac8..f414422c 100644
--- 
a/runtime/src/main/java/org/apache/flink/agents/runtime/memory/Mem0LongTermMemory.java
+++ 
b/runtime/src/main/java/org/apache/flink/agents/runtime/memory/Mem0LongTermMemory.java
@@ -44,7 +44,7 @@ public class Mem0LongTermMemory implements 
InteranlBaseLongTermMemory {
     private static final String MEM0_ITEMS_TO_JAVA = 
"python_java_utils.mem0_items_to_java";
 
     private final PythonResourceAdapter adapter;
-    private final PyObject pyMem0;
+    private PyObject pyMem0;
 
     public Mem0LongTermMemory(PythonResourceAdapter adapter, PyObject pyMem0) {
         this.adapter = adapter;
@@ -168,8 +168,15 @@ public class Mem0LongTermMemory implements 
InteranlBaseLongTermMemory {
     }
 
     @Override
-    public void close() {
-        adapter.callMethod(pyMem0, "close", Map.of());
+    public void close() throws Exception {
+        // Clear first because Pemja's close performs an unguarded native 
decRef.
+        PyObject memory = pyMem0;
+        pyMem0 = null;
+        if (memory != null) {
+            try (memory) {
+                adapter.callMethod(memory, "close", Map.of());
+            }
+        }
     }
 
     private Object buildPyMemorySet(MemorySet memorySet) {
diff --git 
a/runtime/src/main/java/org/apache/flink/agents/runtime/operator/PythonBridgeManager.java
 
b/runtime/src/main/java/org/apache/flink/agents/runtime/operator/PythonBridgeManager.java
index 3c4127ac..0383faee 100644
--- 
a/runtime/src/main/java/org/apache/flink/agents/runtime/operator/PythonBridgeManager.java
+++ 
b/runtime/src/main/java/org/apache/flink/agents/runtime/operator/PythonBridgeManager.java
@@ -58,17 +58,19 @@ import static 
org.apache.flink.agents.plan.actions.Utils.supportAsync;
  * <ul>
  *   <li>The {@link PythonEnvironmentManager} that prepares dependencies and 
the Pemja runtime.
  *   <li>The {@link PythonInterpreter} obtained from that environment.
- *   <li>The {@link PythonActionExecutor} (when the plan contains Python 
actions).
+ *   <li>The {@link PythonActionExecutor} (when the plan contains Python 
actions or Mem0).
  *   <li>The {@link PythonRunnerContextImpl} consumed by Python actions.
  *   <li>The Java/Python resource adapters that bridge resource lookups across 
languages.
+ *   <li>The Java wrapper around Python Mem0 long-term memory (when 
configured).
  * </ul>
  *
  * <p>Lifecycle: instantiated by the operator's {@code open()} (lazy — not in 
the operator
  * constructor), then immediately initialized via {@link #open} in the same 
call. {@link #open} is a
- * no-op when the agent plan contains no Python actions and no Python 
resources — in that case all
- * accessors return {@code null} and {@link #isInitialized()} returns {@code 
false}. {@link
- * #close()} closes the owned resources in the reverse order of creation: 
{@code
- * pythonActionExecutor} → {@code pythonInterpreter} → {@code 
pythonEnvironmentManager}.
+ * no-op when the agent plan contains no Python actions, Python resources, or 
Mem0 configuration —
+ * in that case all accessors return {@code null} and {@link #isInitialized()} 
returns {@code
+ * false}. {@link #close()} closes the owned resources in the reverse order of 
creation: {@code
+ * longTermMemory} → {@code pythonActionExecutor} → {@code 
pythonResourceAdapter} → {@code
+ * pythonInterpreter} → {@code pythonEnvironmentManager}.
  *
  * <p>Design constraint: package-private; no manager-to-manager held 
references. Other managers
  * receive what they need (e.g. the Python runner context, the action 
executor) via method
@@ -326,7 +328,11 @@ class PythonBridgeManager implements AutoCloseable {
         Throwable firstFailure = null;
         for (AutoCloseable closeable :
                 new AutoCloseable[] {
-                    pythonActionExecutor, pythonInterpreter, 
pythonEnvironmentManager
+                    longTermMemory,
+                    pythonActionExecutor,
+                    pythonResourceAdapter,
+                    pythonInterpreter,
+                    pythonEnvironmentManager
                 }) {
             if (closeable == null) {
                 continue;
diff --git 
a/runtime/src/main/java/org/apache/flink/agents/runtime/python/utils/PythonActionExecutor.java
 
b/runtime/src/main/java/org/apache/flink/agents/runtime/python/utils/PythonActionExecutor.java
index 39f73f42..f39958e0 100644
--- 
a/runtime/src/main/java/org/apache/flink/agents/runtime/python/utils/PythonActionExecutor.java
+++ 
b/runtime/src/main/java/org/apache/flink/agents/runtime/python/utils/PythonActionExecutor.java
@@ -212,28 +212,39 @@ public class PythonActionExecutor implements 
AutoCloseable {
         if (interpreter == null) {
             return;
         }
+
+        // Clear the fields before releasing: PyObject.close() performs an 
unguarded native decRef,
+        // so a repeated close() must not reach the same handle twice.
+        PyObject asyncThreadPool = pythonAsyncThreadPool;
+        PyObject runnerContext = pythonRunnerContext;
+        pythonAsyncThreadPool = null;
+        pythonRunnerContext = null;
+
         Throwable firstFailure = null;
-        if (pythonAsyncThreadPool != null) {
-            try {
-                interpreter.invoke(CLOSE_ASYNC_THREAD_POOL, 
pythonAsyncThreadPool);
-            } catch (Throwable t) {
-                firstFailure = ExceptionUtils.firstOrSuppressed(t, 
firstFailure);
-            }
+        try {
+            closePythonObject(CLOSE_ASYNC_THREAD_POOL, asyncThreadPool);
+        } catch (Throwable t) {
+            firstFailure = ExceptionUtils.firstOrSuppressed(t, firstFailure);
         }
-        if (pythonRunnerContext != null) {
-            try {
-                interpreter.invoke(CLOSE_FLINK_RUNNER_CONTEXT, 
pythonRunnerContext);
-            } catch (Throwable t) {
-                firstFailure = ExceptionUtils.firstOrSuppressed(t, 
firstFailure);
-            } finally {
-                pythonRunnerContext = null;
-            }
+        try {
+            closePythonObject(CLOSE_FLINK_RUNNER_CONTEXT, runnerContext);
+        } catch (Throwable t) {
+            firstFailure = ExceptionUtils.firstOrSuppressed(t, firstFailure);
         }
+
         if (firstFailure != null) {
             ExceptionUtils.rethrowException(firstFailure);
         }
     }
 
+    private void closePythonObject(String closeFunction, PyObject 
pythonObject) throws Exception {
+        if (pythonObject != null) {
+            try (pythonObject) {
+                interpreter.invoke(closeFunction, pythonObject);
+            }
+        }
+    }
+
     /** Failed to execute Python action. */
     public static class PythonActionExecutionException extends Exception {
         public PythonActionExecutionException(String message, Throwable cause) 
{
diff --git 
a/runtime/src/main/java/org/apache/flink/agents/runtime/python/utils/PythonResourceAdapterImpl.java
 
b/runtime/src/main/java/org/apache/flink/agents/runtime/python/utils/PythonResourceAdapterImpl.java
index 5cf4aec0..31c89713 100644
--- 
a/runtime/src/main/java/org/apache/flink/agents/runtime/python/utils/PythonResourceAdapterImpl.java
+++ 
b/runtime/src/main/java/org/apache/flink/agents/runtime/python/utils/PythonResourceAdapterImpl.java
@@ -38,7 +38,7 @@ import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 
-public class PythonResourceAdapterImpl implements PythonResourceAdapter {
+public class PythonResourceAdapterImpl implements PythonResourceAdapter, 
AutoCloseable {
 
     static final String PYTHON_IMPORTS = "from flink_agents.runtime import 
python_java_utils";
 
@@ -99,6 +99,16 @@ public class PythonResourceAdapterImpl implements 
PythonResourceAdapter {
         pythonResourceContext = (PyObject) 
interpreter.invoke(GET_RESOURCE_CONTEXT, this);
     }
 
+    @Override
+    public void close() throws Exception {
+        // Clear first because Pemja's close performs an unguarded native 
decRef.
+        PyObject resourceContext = pythonResourceContext;
+        pythonResourceContext = null;
+        if (resourceContext != null) {
+            resourceContext.close();
+        }
+    }
+
     public Object getResource(String resourceName, String resourceType) {
         Resource resource;
         try {
diff --git 
a/runtime/src/test/java/org/apache/flink/agents/runtime/memory/Mem0LongTermMemoryTest.java
 
b/runtime/src/test/java/org/apache/flink/agents/runtime/memory/Mem0LongTermMemoryTest.java
index f2575be4..72498d77 100644
--- 
a/runtime/src/test/java/org/apache/flink/agents/runtime/memory/Mem0LongTermMemoryTest.java
+++ 
b/runtime/src/test/java/org/apache/flink/agents/runtime/memory/Mem0LongTermMemoryTest.java
@@ -32,8 +32,10 @@ import java.util.List;
 import java.util.Map;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
@@ -183,11 +185,12 @@ public class Mem0LongTermMemoryTest {
     }
 
     @Test
-    void testSwitchContextAndCloseForward() {
+    void testSwitchContextAndCloseForward() throws Exception {
         ltm.configureObservation(true, false, true);
         ltm.switchContext("k1", "observation-1", true);
         ltm.drainObservationRecordsJson("k1", "observation-1");
         ltm.close();
+        ltm.close();
 
         verify(mockAdapter)
                 .callMethod(
@@ -219,5 +222,18 @@ public class Mem0LongTermMemoryTest {
                         eq("drain_ltm_observation_records"),
                         eq(Map.of("key", "k1", "observation_id", 
"observation-1")));
         verify(mockAdapter).callMethod(eq(mockPyMem0), eq("close"), 
eq(Map.of()));
+        verify(mockPyMem0).close();
+    }
+
+    @Test
+    void testCloseReleasesPythonObjectWhenLogicalCleanupFails() throws 
Exception {
+        RuntimeException failure = new RuntimeException("logical close 
failed");
+        doThrow(failure).when(mockAdapter).callMethod(mockPyMem0, "close", 
Map.of());
+
+        assertThatThrownBy(ltm::close).isSameAs(failure);
+        ltm.close();
+
+        verify(mockAdapter).callMethod(mockPyMem0, "close", Map.of());
+        verify(mockPyMem0).close();
     }
 }
diff --git 
a/runtime/src/test/java/org/apache/flink/agents/runtime/operator/PythonBridgeManagerTest.java
 
b/runtime/src/test/java/org/apache/flink/agents/runtime/operator/PythonBridgeManagerTest.java
index b4daf548..fa25afff 100644
--- 
a/runtime/src/test/java/org/apache/flink/agents/runtime/operator/PythonBridgeManagerTest.java
+++ 
b/runtime/src/test/java/org/apache/flink/agents/runtime/operator/PythonBridgeManagerTest.java
@@ -21,7 +21,9 @@ import org.apache.flink.agents.api.InputEvent;
 import org.apache.flink.agents.plan.AgentPlan;
 import org.apache.flink.agents.plan.actions.Action;
 import org.apache.flink.agents.runtime.env.PythonEnvironmentManager;
+import org.apache.flink.agents.runtime.memory.Mem0LongTermMemory;
 import org.apache.flink.agents.runtime.python.utils.PythonActionExecutor;
+import org.apache.flink.agents.runtime.python.utils.PythonResourceAdapterImpl;
 import org.apache.flink.api.common.ExecutionConfig;
 import org.apache.flink.api.common.JobID;
 import org.junit.jupiter.api.Test;
@@ -42,6 +44,50 @@ import static org.mockito.Mockito.verify;
 /** Contract tests for {@link PythonBridgeManager}. */
 class PythonBridgeManagerTest {
 
+    @Test
+    void closeAttemptsAllResourcesAndSuppressesLaterFailures() throws 
Exception {
+        PythonBridgeManager bridge = new PythonBridgeManager();
+        Mem0LongTermMemory longTermMemory = mock(Mem0LongTermMemory.class);
+        PythonActionExecutor actionExecutor = mock(PythonActionExecutor.class);
+        PythonResourceAdapterImpl resourceAdapter = 
mock(PythonResourceAdapterImpl.class);
+        PythonInterpreter interpreter = mock(PythonInterpreter.class);
+        PythonEnvironmentManager environmentManager = 
mock(PythonEnvironmentManager.class);
+        RuntimeException actionExecutorFailure =
+                new RuntimeException("action executor close failed");
+        RuntimeException interpreterFailure = new 
RuntimeException("interpreter close failed");
+        RuntimeException environmentFailure = new 
RuntimeException("environment close failed");
+
+        doThrow(actionExecutorFailure).when(actionExecutor).close();
+        RuntimeException resourceAdapterFailure =
+                new RuntimeException("resource adapter close failed");
+        doThrow(resourceAdapterFailure).when(resourceAdapter).close();
+        doThrow(interpreterFailure).when(interpreter).close();
+        doThrow(environmentFailure).when(environmentManager).close();
+        setField(bridge, "longTermMemory", longTermMemory);
+        setField(bridge, "pythonActionExecutor", actionExecutor);
+        setField(bridge, "pythonResourceAdapter", resourceAdapter);
+        setField(bridge, "pythonInterpreter", interpreter);
+        setField(bridge, "pythonEnvironmentManager", environmentManager);
+
+        assertThatThrownBy(bridge::close)
+                .isSameAs(actionExecutorFailure)
+                .hasSuppressedException(resourceAdapterFailure)
+                .hasSuppressedException(interpreterFailure)
+                .hasSuppressedException(environmentFailure);
+        InOrder closeOrder =
+                inOrder(
+                        longTermMemory,
+                        actionExecutor,
+                        resourceAdapter,
+                        interpreter,
+                        environmentManager);
+        closeOrder.verify(longTermMemory).close();
+        closeOrder.verify(actionExecutor).close();
+        closeOrder.verify(resourceAdapter).close();
+        closeOrder.verify(interpreter).close();
+        closeOrder.verify(environmentManager).close();
+    }
+
     @Test
     void openIsNoOpWhenPlanHasNeitherPythonActionsNorResources() throws 
Exception {
         // Java-only plan: one Java action, no resources.
diff --git 
a/runtime/src/test/java/org/apache/flink/agents/runtime/python/utils/PythonActionExecutorTest.java
 
b/runtime/src/test/java/org/apache/flink/agents/runtime/python/utils/PythonActionExecutorTest.java
index 5169269e..5bdac5dc 100644
--- 
a/runtime/src/test/java/org/apache/flink/agents/runtime/python/utils/PythonActionExecutorTest.java
+++ 
b/runtime/src/test/java/org/apache/flink/agents/runtime/python/utils/PythonActionExecutorTest.java
@@ -17,21 +17,41 @@
  */
 package org.apache.flink.agents.runtime.python.utils;
 
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.flink.agents.api.agents.AgentExecutionOptions;
+import org.apache.flink.agents.plan.AgentPlan;
+import org.apache.flink.agents.runtime.python.context.PythonRunnerContextImpl;
 import org.apache.flink.types.Row;
 import org.junit.jupiter.api.Test;
+import org.mockito.InOrder;
 import pemja.core.PythonInterpreter;
 import pemja.core.object.PyObject;
 
 import java.lang.reflect.Field;
+import java.util.HashMap;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.clearInvocations;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.inOrder;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
 import static org.mockito.Mockito.when;
 
+/** Tests for {@link PythonActionExecutor}. */
 class PythonActionExecutorTest {
 
+    private static final String CREATE_ASYNC_THREAD_POOL =
+            "flink_runner_context.create_async_thread_pool";
+    private static final String CLOSE_ASYNC_THREAD_POOL =
+            "flink_runner_context.close_async_thread_pool";
+    private static final String CREATE_FLINK_RUNNER_CONTEXT =
+            "flink_runner_context.create_flink_runner_context";
+    private static final String CLOSE_FLINK_RUNNER_CONTEXT =
+            "flink_runner_context.close_flink_runner_context";
+
     @Test
     void resolvesPickledPythonKeyTextFromPyFlinkKeyRow() throws Exception {
         PythonInterpreter interpreter = mock(PythonInterpreter.class);
@@ -172,8 +192,107 @@ class PythonActionExecutorTest {
         field.set(executor, value);
     }
 
+    @Test
+    void releasesPythonObjectsAfterLogicalCleanup() throws Exception {
+        TestFixture fixture = createOpenedExecutor();
+        clearInvocations(fixture.interpreter, fixture.asyncThreadPool, 
fixture.runnerContextObject);
+
+        fixture.executor.close();
+
+        InOrder closeOrder =
+                inOrder(fixture.interpreter, fixture.asyncThreadPool, 
fixture.runnerContextObject);
+        closeOrder
+                .verify(fixture.interpreter)
+                .invoke(CLOSE_ASYNC_THREAD_POOL, fixture.asyncThreadPool);
+        closeOrder.verify(fixture.asyncThreadPool).close();
+        closeOrder
+                .verify(fixture.interpreter)
+                .invoke(CLOSE_FLINK_RUNNER_CONTEXT, 
fixture.runnerContextObject);
+        closeOrder.verify(fixture.runnerContextObject).close();
+    }
+
+    @Test
+    void releasesBothPythonObjectsWhenLogicalCleanupFails() throws Exception {
+        TestFixture fixture = createOpenedExecutor();
+        RuntimeException asyncFailure = new RuntimeException("async cleanup 
failed");
+        RuntimeException contextFailure = new RuntimeException("context 
cleanup failed");
+        doThrow(asyncFailure)
+                .when(fixture.interpreter)
+                .invoke(CLOSE_ASYNC_THREAD_POOL, fixture.asyncThreadPool);
+        doThrow(contextFailure)
+                .when(fixture.interpreter)
+                .invoke(CLOSE_FLINK_RUNNER_CONTEXT, 
fixture.runnerContextObject);
+
+        assertThatThrownBy(fixture.executor::close)
+                .isSameAs(asyncFailure)
+                .hasSuppressedException(contextFailure);
+        InOrder closeOrder =
+                inOrder(fixture.interpreter, fixture.asyncThreadPool, 
fixture.runnerContextObject);
+        closeOrder
+                .verify(fixture.interpreter)
+                .invoke(CLOSE_ASYNC_THREAD_POOL, fixture.asyncThreadPool);
+        closeOrder.verify(fixture.asyncThreadPool).close();
+        closeOrder
+                .verify(fixture.interpreter)
+                .invoke(CLOSE_FLINK_RUNNER_CONTEXT, 
fixture.runnerContextObject);
+        closeOrder.verify(fixture.runnerContextObject).close();
+
+        clearInvocations(fixture.interpreter, fixture.asyncThreadPool, 
fixture.runnerContextObject);
+        fixture.executor.close();
+        verifyNoInteractions(
+                fixture.interpreter, fixture.asyncThreadPool, 
fixture.runnerContextObject);
+    }
+
     private static PythonActionExecutor newExecutor(PythonInterpreter 
interpreter)
             throws Exception {
         return new PythonActionExecutor(interpreter, null, null, null, 
"test-job");
     }
+
+    private static TestFixture createOpenedExecutor() throws Exception {
+        PythonInterpreter interpreter = mock(PythonInterpreter.class);
+        PythonRunnerContextImpl runnerContext = 
mock(PythonRunnerContextImpl.class);
+        JavaResourceAdapter resourceAdapter = mock(JavaResourceAdapter.class);
+        PyObject asyncThreadPool = mock(PyObject.class);
+        PyObject runnerContextObject = mock(PyObject.class);
+        AgentPlan plan = new AgentPlan(new HashMap<>(), new HashMap<>());
+        String planJson = new ObjectMapper().writeValueAsString(plan);
+        String jobIdentifier = "job-1";
+
+        when(interpreter.invoke(
+                        CREATE_ASYNC_THREAD_POOL,
+                        
plan.getConfig().get(AgentExecutionOptions.NUM_ASYNC_THREADS)))
+                .thenReturn(asyncThreadPool);
+        when(interpreter.invoke(
+                        CREATE_FLINK_RUNNER_CONTEXT,
+                        runnerContext,
+                        planJson,
+                        asyncThreadPool,
+                        resourceAdapter,
+                        jobIdentifier))
+                .thenReturn(runnerContextObject);
+
+        PythonActionExecutor executor =
+                new PythonActionExecutor(
+                        interpreter, plan, resourceAdapter, runnerContext, 
jobIdentifier);
+        executor.open();
+        return new TestFixture(interpreter, asyncThreadPool, 
runnerContextObject, executor);
+    }
+
+    private static final class TestFixture {
+        private final PythonInterpreter interpreter;
+        private final PyObject asyncThreadPool;
+        private final PyObject runnerContextObject;
+        private final PythonActionExecutor executor;
+
+        private TestFixture(
+                PythonInterpreter interpreter,
+                PyObject asyncThreadPool,
+                PyObject runnerContextObject,
+                PythonActionExecutor executor) {
+            this.interpreter = interpreter;
+            this.asyncThreadPool = asyncThreadPool;
+            this.runnerContextObject = runnerContextObject;
+            this.executor = executor;
+        }
+    }
 }
diff --git 
a/runtime/src/test/java/org/apache/flink/agents/runtime/python/utils/PythonResourceAdapterImplTest.java
 
b/runtime/src/test/java/org/apache/flink/agents/runtime/python/utils/PythonResourceAdapterImplTest.java
index e46e3ea6..f372821a 100644
--- 
a/runtime/src/test/java/org/apache/flink/agents/runtime/python/utils/PythonResourceAdapterImplTest.java
+++ 
b/runtime/src/test/java/org/apache/flink/agents/runtime/python/utils/PythonResourceAdapterImplTest.java
@@ -92,6 +92,20 @@ public class PythonResourceAdapterImplTest {
                 .invoke(PythonResourceAdapterImpl.GET_RESOURCE_CONTEXT, 
pythonResourceAdapter);
     }
 
+    @Test
+    void testCloseReleasesPythonResourceContextOnce() throws Exception {
+        PyObject pythonResourceContext = mock(PyObject.class);
+        when(mockInterpreter.invoke(
+                        PythonResourceAdapterImpl.GET_RESOURCE_CONTEXT, 
pythonResourceAdapter))
+                .thenReturn(pythonResourceContext);
+        pythonResourceAdapter.open();
+
+        pythonResourceAdapter.close();
+        pythonResourceAdapter.close();
+
+        verify(pythonResourceContext).close();
+    }
+
     @Test
     void testGetResourceWithPythonResourceWrapper() throws Exception {
         String resourceName = "test_resource";

Reply via email to