weiqingy commented on code in PR #987:
URL: https://github.com/apache/flink-agents/pull/987#discussion_r3742706633


##########
runtime/src/main/java/org/apache/flink/agents/runtime/operator/PythonBridgeManager.java:
##########
@@ -299,14 +300,29 @@ boolean isInitialized() {
 
     @Override
     public void close() throws Exception {
-        if (pythonActionExecutor != null) {
-            pythonActionExecutor.close();
-        }
-        if (pythonInterpreter != null) {
-            pythonInterpreter.close();
+        // Close every component even when an earlier one fails, so a failing 
action executor
+        // cannot leak the interpreter or the environment manager. The first 
failure is
+        // rethrown with the later ones suppressed.
+        //
+        // The ladder catches Throwable, not Exception, and IOUtils.closeAll 
is deliberately not
+        // used: both stop at the first non-Exception Throwable without 
closing what follows, and
+        // what follows here is the native Python state.

Review Comment:
   Worth knowing about #944, which is open and takes the other path here: it 
rewrites these same three `close()` methods with `IOUtils.closeAll(...)`, and 
it touches all seven files this PR does.
   
   Your reasoning in this comment holds up as far as I can tell. `closeAll` 
defaults `suppressedException` to `Exception.class` and rethrows anything that 
is not one before closing the rest (flink-core 2.3.0), so 
`closeReleasesInterpreterAndEnvironmentWhenActionExecutorThrowsError` would 
fail against #944's version of this method. The component lists differ too, 
since that call also passes `longTermMemory` and `pythonResourceAdapter`.
   
   So the two disagree on the mechanism, not just on the same seven files, and 
whichever lands second inherits the other's decision. Either way the `Error` 
case looks like the thing to settle first. How would you like to sequence them?



##########
runtime/src/main/java/org/apache/flink/agents/runtime/operator/ActionExecutionOperator.java:
##########
@@ -553,24 +553,38 @@ public void waitInFlightEventsFinished() throws Exception 
{
 
     @Override
     public void close() throws Exception {
-        // Must close before pythonInterpreter since cached resources may hold 
Python references.
-        if (resourceCache != null) {
-            resourceCache.close();
-        }
-        if (contextManager != null) {
-            contextManager.close();
-        }
-        if (pythonBridge != null) {
-            pythonBridge.close();
-        }
-        if (eventRouter != null) {
-            eventRouter.close();
+        // Close every component even when an earlier one fails, so a failing 
close cannot leak
+        // the components behind it or skip super.close(). The first failure 
is rethrown with
+        // the later ones suppressed. Order is preserved: the resource cache 
must close before
+        // pythonInterpreter since cached resources may hold Python references.
+        //
+        // The ladder catches Throwable, not Exception, and IOUtils.closeAll 
is deliberately not
+        // used: both stop at the first non-Exception Throwable without 
closing what follows,
+        // which is the very leak this method has to avoid.
+        Throwable firstFailure = null;
+        for (AutoCloseable closeable :
+                new AutoCloseable[] {
+                    resourceCache, contextManager, pythonBridge, eventRouter, 
durableExecManager

Review Comment:
   Something I noticed while reading the order comment above. `resourceCache` 
closes first because cached resources may hold Python references, so they need 
to go before the interpreter.
   
   `ResourceCache.close()` itself still catches only `Exception` 
(`ResourceCache.java:148` and `:160`), which is the shape this comment argues 
against. If a cached `Resource.close()` throws something that is not an 
`Exception`, it propagates straight out: the remaining cached resources are 
skipped, `cache.clear()` never runs, and `resourceContext.close()` never runs. 
This ladder then catches it and closes `pythonBridge` anyway, so the 
interpreter goes down while those resources still point at it.
   
   Not a regression, and an `Error` out of `Resource.close()` is unlikely in 
practice. What made me look was the PR body citing `ResourceCache.close()` as 
prior art for the aggregation shape. Is it intentionally left as-is, or would 
widening those two catches be in scope here?



##########
runtime/src/test/java/org/apache/flink/agents/runtime/operator/ActionExecutionOperatorTest.java:
##########
@@ -525,6 +529,65 @@ private static void replaceOperatorLtm(
         ltmField.set(operator, ltm);
     }
 
+    private static void replaceOperatorField(
+            ActionExecutionOperator<?, ?> operator, String name, Object value) 
throws Exception {
+        Field field = ActionExecutionOperator.class.getDeclaredField(name);
+        field.setAccessible(true);
+        field.set(operator, value);
+    }
+
+    /**
+     * A failing component must not strand the ones behind it. This matters 
most for {@code
+     * resourceCache}, which closes first and aggregates its own failures, and 
for {@code
+     * pythonBridge}, which releases the embedded Python interpreter.
+     */
+    @Test
+    void closeClosesEveryComponentWhenAnEarlierCloseFails() throws Exception {
+        KeyedOneInputStreamOperatorTestHarness<Long, Long, Object> testHarness 
=
+                new KeyedOneInputStreamOperatorTestHarness<>(
+                        new 
ActionExecutionOperatorFactory(TestAgent.getAgentPlan(false), true),
+                        (KeySelector<Long, Long>) value -> value,
+                        TypeInformation.of(Long.class));
+        testHarness.open();
+        ActionExecutionOperator<Long, Object> operator =
+                (ActionExecutionOperator<Long, Object>) 
testHarness.getOperator();
+
+        ResourceCache resourceCache = mock(ResourceCache.class);
+        ActionTaskContextManager contextManager = 
mock(ActionTaskContextManager.class);
+        PythonBridgeManager pythonBridge = mock(PythonBridgeManager.class);
+        EventRouter<Long, Object> eventRouter = mock(EventRouter.class);
+        DurableExecutionManager durableExecManager = 
mock(DurableExecutionManager.class);
+        doThrow(new IllegalStateException("resource cache close failed"))
+                .when(resourceCache)
+                .close();
+
+        replaceOperatorField(operator, "resourceCache", resourceCache);
+        replaceOperatorField(operator, "contextManager", contextManager);
+        replaceOperatorField(operator, "pythonBridge", pythonBridge);
+        replaceOperatorField(operator, "eventRouter", eventRouter);
+        replaceOperatorField(operator, "durableExecManager", 
durableExecManager);
+
+        try {
+            assertThatThrownBy(operator::close)
+                    .isInstanceOf(IllegalStateException.class)
+                    .hasMessage("resource cache close failed");
+
+            // The components behind the failing one are still released.
+            verify(contextManager).close();
+            verify(pythonBridge).close();
+            verify(eventRouter).close();
+            verify(durableExecManager).close();

Review Comment:
   The test table maps this one to contracts 1 and 7. Contract 1 is well 
covered by the four `verify`s, but I cannot find anything here that looks at 
`super.close()`, and there is no case where it fails, so the aggregation half 
of contract 7 is not exercised.
   
   I have not run this, so treat what follows as reasoning rather than a 
result. Two changes both look like they would leave this test green: deleting 
the try/catch at `ActionExecutionOperator.java:579-583`, or restoring just the 
old shape for the super call (`if (firstFailure == null) super.close();`). 
Neither the throw nor the four `verify`s depend on it, and the `finally` 
empties the ladder before teardown.
   
   That second one is the interesting case, since it is a partial revert of 
what this PR fixes. `AbstractStreamOperator.close()` is 
`stateHandler.dispose()`, so skipping it strands the state backends.
   
   The "it still runs" half looks cheap to pin, because a side effect of 
`dispose()` is visible once the throwing `close()` returns. Making it actually 
fail is harder, since `super.close()` binds statically and a subclass cannot 
intercept it. Would an assertion for the first half be worth it, or would you 
rather drop contract 7 from the table?



##########
runtime/src/test/java/org/apache/flink/agents/runtime/operator/PythonBridgeManagerTest.java:
##########
@@ -59,4 +67,92 @@ void openIsNoOpWhenPlanHasNeitherPythonActionsNorResources() 
throws Exception {
             assertThat(bridge.getPythonRunnerContext()).isNull();
         }
     }
+
+    /**
+     * A failing action executor must not strand the interpreter or the 
environment manager: both
+     * hold native Python state that leaks for the lifetime of the TaskManager 
if never closed.
+     */
+    @Test
+    void closeReleasesInterpreterAndEnvironmentWhenActionExecutorFails() 
throws Exception {
+        PythonBridgeManager bridge = new PythonBridgeManager();
+        PythonActionExecutor actionExecutor = mock(PythonActionExecutor.class);
+        PythonInterpreter interpreter = mock(PythonInterpreter.class);
+        PythonEnvironmentManager environmentManager = 
mock(PythonEnvironmentManager.class);
+        doThrow(new IllegalStateException("action executor close failed"))
+                .when(actionExecutor)
+                .close();
+
+        setField(bridge, "pythonActionExecutor", actionExecutor);
+        setField(bridge, "pythonInterpreter", interpreter);
+        setField(bridge, "pythonEnvironmentManager", environmentManager);
+
+        assertThatThrownBy(bridge::close)
+                .isInstanceOf(IllegalStateException.class)
+                .hasMessage("action executor close failed");
+
+        verify(interpreter).close();
+        verify(environmentManager).close();

Review Comment:
   Contract 1 mentions the existing order, and I am curious how much weight 
that clause is meant to carry. `verify()` does not check order, and none of the 
new tests use `InOrder`, so swapping the array at 
`PythonBridgeManager.java:311-314` to `{pythonInterpreter, 
pythonActionExecutor, pythonEnvironmentManager}` leaves all three tests in this 
file green. The action executor still throws, and it is still the first failure 
even though it is no longer the first close.
   
   Order does look load-bearing rather than incidental: the class javadoc at 
`:70-71` documents reverse-of-creation order, and 
`PythonActionExecutor.close()` calls into the interpreter twice 
(`PythonActionExecutor.java:205-219`), which throws once the interpreter is 
already closed.
   
   Something like this in one of the three, if useful:
   
   ```java
   InOrder inOrder = inOrder(actionExecutor, interpreter, environmentManager);
   inOrder.verify(actionExecutor).close();
   inOrder.verify(interpreter).close();
   inOrder.verify(environmentManager).close();
   ```
   
   One test would be enough to pin it. Does that seem worth it?



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to