[runtime][python] Release bridge-owned Pemja objects during cleanup - #944
[runtime][python] Release bridge-owned Pemja objects during cleanup#944joeyutong wants to merge 5 commits into
Conversation
59a8307 to
b761707
Compare
weiqingy
left a comment
There was a problem hiding this comment.
Thanks for chasing this one down. I reverted close() locally and both new tests fail against the old body, so the regression coverage is real. A few questions inline.
| if (pythonAsyncThreadPool != null) { | ||
| interpreter.invoke(CLOSE_ASYNC_THREAD_POOL, pythonAsyncThreadPool); | ||
| } | ||
| PyObject asyncThreadPool = pythonAsyncThreadPool; |
There was a problem hiding this comment.
nit: The copy-then-null reads as defensive style, but it looks load-bearing. PyObject.close() in pemja 0.5.7 is an unguarded decRef(tState, pyobject) with no null check and no double-close flag, so clearing the fields first is the only thing stopping a repeated close() from decrementing a second time on an already-released handle. Someone later tidying this into closePythonObject(CLOSE_ASYNC_THREAD_POOL, pythonAsyncThreadPool) would drop that quietly, and the only assertion that would notice is the three-line tail of releasesBothPythonObjectsWhenLogicalCleanupFails.
Would a short comment here save the next reader that trip? There is precedent right next door at ActionExecutionOperator.java:471 (// Must close before pythonInterpreter since cached resources may hold Python references.).
Something like this, if it helps:
// Clear the fields before releasing: PyObject.close() is an unguarded native decRef,
// so a repeated close() must not reach the same handle twice.There was a problem hiding this comment.
Good point. I added a short comment explaining why the fields are cleared before releasing the Pemja handles, so a repeated close() cannot reach the same handle twice.
| } | ||
|
|
||
| if (exception != null) { | ||
| throw exception; |
There was a problem hiding this comment.
Combining both failures and rethrowing is the right call. What I keep looking at is what happens to this exception one frame up:
// PythonBridgeManager.close(), lines 292-302
if (pythonActionExecutor != null) { pythonActionExecutor.close(); }
if (pythonInterpreter != null) { pythonInterpreter.close(); }
if (pythonEnvironmentManager != null) { pythonEnvironmentManager.close(); }That is a plain sequence, so on exactly the failure path this PR is built for, pythonInterpreter.close() never runs, and that is the release that tears down the interpreter owning every handle still outstanding. ActionExecutionOperator.close() (lines 469-489) has the same shape across its five closes.
To be clear, this is pre-existing. The old close() threw on a failed interpreter.invoke too, so nothing has regressed here. But given the stated goal is "releases both handles even if one cleanup operation fails", how do you see that goal holding one frame up? Carrying the same firstOrSuppressed pattern into PythonBridgeManager.close() would make the guarantee end-to-end, though I may be missing a reason the interpreter is fine to leak on that path.
There was a problem hiding this comment.
Great catch. The guarantee did not hold one frame up. I extended best-effort cleanup through both PythonBridgeManager and ActionExecutionOperator, preserving close order and suppressing later failures, with tests at both layers.
| } | ||
| private void closePythonObject(String closeFunction, PyObject pythonObject) throws Exception { | ||
| if (pythonObject != null) { | ||
| try (pythonObject) { |
There was a problem hiding this comment.
This is the shape the whole fix turns on: logical cleanup inside, native release on the way out. Two other Pemja handles in the same lifecycle still have the pre-PR shape.
Mem0LongTermMemory.close() (Mem0LongTermMemory.java:137-140) calls adapter.callMethod(pyMem0, "close", Map.of()) and never pyMem0.close(), which is the old PythonActionExecutor.close() exactly. It is live rather than dead code: RunnerContextImpl.java:339-345 calls ltm.close(), and the handle comes from PythonBridgeManager.java:237.
PythonResourceAdapterImpl.pythonResourceContext (PythonResourceAdapterImpl.java:86,99) is built as interpreter.invoke(GET_RESOURCE_CONTEXT, this), so it is a Java object handed into Python, the same JNI-global-ref pattern #942 describes. That class has no close() at all, and PythonBridgeManager.close() never touches the adapter.
I read the PR and #942 as deliberately scoped to the action executor, so I am not suggesting you widen this one. Is a follow-up issue the plan for the sibling handles, or is there something that already releases those two that I have missed?
There was a problem hiding this comment.
Good catch. Both handles are live, and the A/B heap dumps confirmed the same retention shape, so I widened this PR instead of opening a follow-up:
PythonResourceAdapterImplnow owns and closespythonResourceContext; nulling the field first makes repeated close safe.Mem0LongTermMemorynow runs Python-level cleanup and always closespyMem0, including when logical cleanup fails; it is also idempotent.PythonBridgeManagernow closes Mem0 and the resource adapter before the interpreter/environment, while continuing all closes after failures. This also covers Mem0-initialized subtasks that never created a JavaRunnerContext.FlinkRunnerContext.close()clears__ltmbefore cleanup and still closes the resource cache if LTM cleanup fails. This breaks the Python context/Mem0 cycle so releasing the Pemja handles can collapse the full Java -> Python -> Java retention chain.- Added lifecycle, failure-path, and repeated-close tests. In focused 20-restart HPROF A/B runs, each sibling baseline retained 84 target
PyObjecthandles after termination; the native-close variants retained 0.
AI-Contributed/Feature: 0/54 AI-Contributed/UT: 0/149
Co-Authored-By: Codex <noreply@openai.com> AI-Model: gpt-5 AI-Contributed/Feature: 48/48 AI-Contributed/UT: 91/91
Close the Mem0 and Python resource-context handles through PythonBridgeManager, and clear the Python runner context's long-term-memory reference during cleanup. Co-Authored-By: Codex <noreply@openai.com> AI-Model: gpt-5 AI-Contributed/Feature: 62/62 AI-Contributed/UT: 108/108
Use the same null-first ownership transfer for the resource cache and remove the nested try/finally block. Co-Authored-By: Codex <noreply@openai.com> AI-Model: gpt-5 AI-Contributed/Feature: 9/9 AI-Contributed/UT: 0/0
Keep OperatorUtils limited to Flink-version compatibility and place shared close behavior in a package-private runtime utility. Co-Authored-By: Codex <noreply@openai.com> AI-Model: gpt-5 AI-Contributed/Feature: 44/44 AI-Contributed/UT: 0/0
67ab71e to
c872d88
Compare
Linked issue: #942
Purpose of change
The embedded Python bridge owns several Pemja
PyObjecthandles: the async thread pool and runner context inPythonActionExecutor, the Python resource context inPythonResourceAdapterImpl, and the Python Mem0 object inMem0LongTermMemory. Each handle keeps a native Python reference, while Python objects can retain the surrounding Java task graph through Pemja proxies and JNI global references.The existing close paths either performed only Python-level cleanup or did not close the
PyObjectat all. Repeated task failovers could therefore retain handles and old task graphs after the corresponding attempt had closed.This change:
decRef;FlinkRunnerContext -> Mem0LongTermMemory -> FlinkRunnerContextcycle so native-handle release can collapse the full Java -> Python -> Java retention chain.The
PythonBridgeManagerfallback also covers subtasks that initialized Mem0 but never created a JavaRunnerContext, so cleanup does not depend on whether that subtask processed a Java action.Tests
PythonActionExecutorTest,PythonResourceAdapterImplTest,Mem0LongTermMemoryTest,PythonBridgeManagerTest, andActionExecutionOperatorTest.PyObjecthandles after termination in each baseline; the native-close variants retained 0.API
No user-facing API changes.
Documentation
doc-neededdoc-not-neededdoc-included