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 4140c638 [runtime][java] Make embedded Python interpreters
thread-confined (#1092)
4140c638 is described below
commit 4140c6385dfad2bbf20fdc6d391ff47df0984e82
Author: Wenjin Xie <[email protected]>
AuthorDate: Tue Sep 15 16:20:11 2026 +0800
[runtime][java] Make embedded Python interpreters thread-confined (#1092)
Generated-by: Codex 0.144.5 (GPT-5)
Co-authored-by: Codex <[email protected]>
---
.../PythonCollectionManageableVectorStore.java | 4 +-
.../PythonCollectionManageableVectorStoreTest.java | 5 +-
docs/content/docs/development/workflow_agent.md | 14 +
.../ConcurrentChatModelCrossLanguageAgent.java | 108 ++++++
.../test/ConcurrentChatModelCrossLanguageTest.java | 69 ++++
.../resource/test/Mem0LongTermMemoryTest.java | 2 -
.../concurrent_chat_model_cross_language_agent.py | 109 ++++++
.../concurrent_chat_model_cross_language_test.py | 99 +++++
.../flink_agents/runtime/java/java_chat_model.py | 29 +-
.../runtime/tests/test_java_chat_model.py | 63 ++++
.../runtime/async/AsyncExecutorThreadFactory.java | 55 ++-
.../runtime/async/ContinuationActionExecutor.java | 3 +
.../runtime/operator/ActionExecutionOperator.java | 3 +-
.../runtime/operator/ActionTaskContextManager.java | 7 +-
.../runtime/operator/PythonBridgeManager.java | 56 ++-
.../runtime/python/utils/JavaResourceAdapter.java | 37 +-
.../runtime/python/utils/PythonActionExecutor.java | 145 +++++---
.../python/utils/PythonInterpreterManager.java | 409 ++++++++++++++++++++
.../python/utils/PythonResourceAdapterImpl.java | 40 +-
.../runtime/async/ContinuationActionExecutor.java | 23 +-
.../async/AsyncExecutorThreadFactoryTest.java | 25 ++
.../runtime/operator/PythonBridgeManagerTest.java | 32 +-
.../python/utils/JavaResourceAdapterTest.java | 24 +-
.../python/utils/PythonActionExecutorTest.java | 112 +++++-
.../python/utils/PythonInterpreterManagerTest.java | 414 +++++++++++++++++++++
.../utils/PythonResourceAdapterImplTest.java | 12 +-
26 files changed, 1725 insertions(+), 174 deletions(-)
diff --git
a/api/src/main/java/org/apache/flink/agents/api/vectorstores/python/PythonCollectionManageableVectorStore.java
b/api/src/main/java/org/apache/flink/agents/api/vectorstores/python/PythonCollectionManageableVectorStore.java
index 26cce423..6fb1e315 100644
---
a/api/src/main/java/org/apache/flink/agents/api/vectorstores/python/PythonCollectionManageableVectorStore.java
+++
b/api/src/main/java/org/apache/flink/agents/api/vectorstores/python/PythonCollectionManageableVectorStore.java
@@ -70,6 +70,8 @@ public class PythonCollectionManageableVectorStore extends
PythonVectorStore
@Override
public void deleteCollection(String name) throws Exception {
- this.vectorStore.invokeMethod("delete_collection", name);
+ Map<String, Object> kwargs = new HashMap<>();
+ kwargs.put("name", name);
+ this.adapter.callMethod(vectorStore, "delete_collection", kwargs);
}
}
diff --git
a/api/src/test/java/org/apache/flink/agents/api/vectorstores/python/PythonCollectionManageableVectorStoreTest.java
b/api/src/test/java/org/apache/flink/agents/api/vectorstores/python/PythonCollectionManageableVectorStoreTest.java
index 566db3fe..e407bcc4 100644
---
a/api/src/test/java/org/apache/flink/agents/api/vectorstores/python/PythonCollectionManageableVectorStoreTest.java
+++
b/api/src/test/java/org/apache/flink/agents/api/vectorstores/python/PythonCollectionManageableVectorStoreTest.java
@@ -41,6 +41,7 @@ import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
public class PythonCollectionManageableVectorStoreTest {
@@ -157,7 +158,9 @@ public class PythonCollectionManageableVectorStoreTest {
vectorStore.deleteCollection(collectionName);
- verify(mockVectorStore).invokeMethod("delete_collection",
collectionName);
+ verify(mockAdapter)
+ .callMethod(mockVectorStore, "delete_collection",
Map.of("name", collectionName));
+ verifyNoInteractions(mockVectorStore);
}
@Test
diff --git a/docs/content/docs/development/workflow_agent.md
b/docs/content/docs/development/workflow_agent.md
index b8166cd0..90cde7db 100644
--- a/docs/content/docs/development/workflow_agent.md
+++ b/docs/content/docs/development/workflow_agent.md
@@ -657,6 +657,20 @@ To use async execution on JDK 21+, user should append jvm
option `--add-exports=
{{< /tab >}}
{{< /tabs >}}
+{{< hint warning >}}
+Async actions in either language may call cross-language resources. Within one
async execution,
+keep the cross-language call chain to one reverse callback. Only the following
call shapes are
+supported:
+
+- Python action → Java resource → Python callback
+- Java action → Python resource → Java callback
+
+For example, a Python action may use a Java `ChatModelSetup` backed by a Python
+`ChatModelConnection`. Multiple or deeper cross-language callbacks, such as
+Python→Java→Python→Java or Java→Python→Java→Python, are not recommended and
may deadlock
+when an outer call occupies an async or interpreter worker while waiting for
another worker.
+{{< /hint >}}
+
### Cross-language Actions
An action declared in one language can dispatch its body to the other language
by setting a `target` on the decorator/annotation. The decorated function or
annotated method then acts as a stub — it should raise so direct calls outside
the framework fail loud.
diff --git
a/e2e-test/flink-agents-end-to-end-tests-resource-cross-language/src/test/java/org/apache/flink/agents/resource/test/ConcurrentChatModelCrossLanguageAgent.java
b/e2e-test/flink-agents-end-to-end-tests-resource-cross-language/src/test/java/org/apache/flink/agents/resource/test/ConcurrentChatModelCrossLanguageAgent.java
new file mode 100644
index 00000000..f5558c2e
--- /dev/null
+++
b/e2e-test/flink-agents-end-to-end-tests-resource-cross-language/src/test/java/org/apache/flink/agents/resource/test/ConcurrentChatModelCrossLanguageAgent.java
@@ -0,0 +1,108 @@
+/*
+ * 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.
+ */
+
+package org.apache.flink.agents.resource.test;
+
+import org.apache.flink.agents.api.Event;
+import org.apache.flink.agents.api.EventType;
+import org.apache.flink.agents.api.InputEvent;
+import org.apache.flink.agents.api.OutputEvent;
+import org.apache.flink.agents.api.agents.Agent;
+import org.apache.flink.agents.api.annotation.Action;
+import org.apache.flink.agents.api.annotation.ChatModelConnection;
+import org.apache.flink.agents.api.annotation.ChatModelSetup;
+import org.apache.flink.agents.api.chat.messages.ChatMessage;
+import org.apache.flink.agents.api.chat.messages.MessageRole;
+import org.apache.flink.agents.api.chat.model.BaseChatModelConnection;
+import org.apache.flink.agents.api.context.RunnerContext;
+import org.apache.flink.agents.api.event.ChatRequestEvent;
+import org.apache.flink.agents.api.event.ChatResponseEvent;
+import org.apache.flink.agents.api.resource.ResourceContext;
+import org.apache.flink.agents.api.resource.ResourceDescriptor;
+import org.apache.flink.agents.api.resource.ResourceName;
+import org.apache.flink.agents.api.tools.Tool;
+
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+/** Java agent exercising concurrent Java-to-Python-to-Java chat calls. */
+public class ConcurrentChatModelCrossLanguageAgent extends Agent {
+
+ /** Java connection that only returns after two chat requests overlap. */
+ public static class OverlappingJavaChatModelConnection extends
BaseChatModelConnection {
+ private final CountDownLatch concurrentCalls = new CountDownLatch(2);
+
+ public OverlappingJavaChatModelConnection(
+ ResourceDescriptor descriptor, ResourceContext
resourceContext) {
+ super(descriptor, resourceContext);
+ }
+
+ @Override
+ public ChatMessage chat(
+ List<ChatMessage> messages, List<Tool> tools, Map<String,
Object> modelParams) {
+ concurrentCalls.countDown();
+ try {
+ if (!concurrentCalls.await(30, TimeUnit.SECONDS)) {
+ throw new IllegalStateException(
+ "Timed out waiting for concurrent cross-language
chat request.");
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IllegalStateException(
+ "Interrupted while waiting for concurrent
cross-language chat request.", e);
+ }
+
+ ChatMessage request = messages.get(messages.size() - 1);
+ return new ChatMessage(
+ MessageRole.ASSISTANT, "java-connection:" +
request.getContent());
+ }
+ }
+
+ @ChatModelConnection
+ public static ResourceDescriptor overlappingJavaConnection() {
+ return ResourceDescriptor.Builder.newBuilder(
+ OverlappingJavaChatModelConnection.class.getName())
+ .build();
+ }
+
+ @ChatModelSetup
+ public static ResourceDescriptor pythonChatModel() {
+ return
ResourceDescriptor.Builder.newBuilder(ResourceName.ChatModel.PYTHON_WRAPPER_SETUP)
+ .addInitialArgument("pythonClazz",
ResourceName.ChatModel.Python.OLLAMA_SETUP)
+ .addInitialArgument("connection", "overlappingJavaConnection")
+ .addInitialArgument("model", "mock-model")
+ .addInitialArgument("extract_reasoning", false)
+ .build();
+ }
+
+ @Action(EventType.InputEvent)
+ public static void requestChat(Event event, RunnerContext ctx) {
+ String input = String.valueOf(InputEvent.fromEvent(event).getInput());
+ ctx.sendEvent(
+ new ChatRequestEvent(
+ "pythonChatModel", List.of(new
ChatMessage(MessageRole.USER, input))));
+ }
+
+ @Action(EventType.ChatResponseEvent)
+ public static void emitResponse(Event event, RunnerContext ctx) {
+ ChatResponseEvent response = ChatResponseEvent.fromEvent(event);
+ ctx.sendEvent(new OutputEvent(response.getResponse().getContent()));
+ }
+}
diff --git
a/e2e-test/flink-agents-end-to-end-tests-resource-cross-language/src/test/java/org/apache/flink/agents/resource/test/ConcurrentChatModelCrossLanguageTest.java
b/e2e-test/flink-agents-end-to-end-tests-resource-cross-language/src/test/java/org/apache/flink/agents/resource/test/ConcurrentChatModelCrossLanguageTest.java
new file mode 100644
index 00000000..4f909c8e
--- /dev/null
+++
b/e2e-test/flink-agents-end-to-end-tests-resource-cross-language/src/test/java/org/apache/flink/agents/resource/test/ConcurrentChatModelCrossLanguageTest.java
@@ -0,0 +1,69 @@
+/*
+ * 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.
+ */
+
+package org.apache.flink.agents.resource.test;
+
+import org.apache.flink.agents.api.AgentsExecutionEnvironment;
+import org.apache.flink.agents.api.agents.AgentExecutionOptions;
+import org.apache.flink.api.java.functions.KeySelector;
+import org.apache.flink.streaming.api.datastream.DataStream;
+import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
+import org.apache.flink.util.CloseableIterator;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** E2E coverage for concurrent calls through a Python setup and Java
connection. */
+public class ConcurrentChatModelCrossLanguageTest {
+
+ @Test
+ public void testConcurrentPythonSetupWithJavaConnection() throws Exception
{
+ StreamExecutionEnvironment env =
StreamExecutionEnvironment.getExecutionEnvironment();
+ env.setParallelism(1);
+
+ DataStream<String> inputStream = env.fromData("first-request",
"second-request");
+ AgentsExecutionEnvironment agentsEnv =
+ AgentsExecutionEnvironment.getExecutionEnvironment(env);
+ agentsEnv.getConfig().set(AgentExecutionOptions.NUM_ASYNC_THREADS, 2);
+ agentsEnv.getConfig().set(AgentExecutionOptions.CHAT_ASYNC, true);
+
+ DataStream<Object> outputStream =
+ agentsEnv
+ .fromDataStream(inputStream, (KeySelector<String,
String>) value -> value)
+ .apply(new ConcurrentChatModelCrossLanguageAgent())
+ .toDataStream();
+
+ CloseableIterator<Object> results = outputStream.collectAsync();
+ agentsEnv.execute();
+
+ List<String> responses = new ArrayList<>();
+ while (results.hasNext()) {
+ responses.add(String.valueOf(results.next()));
+ }
+
+ assertThat(responses).hasSize(2);
+ assertThat(new HashSet<>(responses))
+ .isEqualTo(
+ Set.of("java-connection:first-request",
"java-connection:second-request"));
+ }
+}
diff --git
a/e2e-test/flink-agents-end-to-end-tests-resource-cross-language/src/test/java/org/apache/flink/agents/resource/test/Mem0LongTermMemoryTest.java
b/e2e-test/flink-agents-end-to-end-tests-resource-cross-language/src/test/java/org/apache/flink/agents/resource/test/Mem0LongTermMemoryTest.java
index 0aa31fd5..c6dce155 100644
---
a/e2e-test/flink-agents-end-to-end-tests-resource-cross-language/src/test/java/org/apache/flink/agents/resource/test/Mem0LongTermMemoryTest.java
+++
b/e2e-test/flink-agents-end-to-end-tests-resource-cross-language/src/test/java/org/apache/flink/agents/resource/test/Mem0LongTermMemoryTest.java
@@ -27,7 +27,6 @@ import
org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.util.CloseableIterator;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Assumptions;
-import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
@@ -61,7 +60,6 @@ import static
org.apache.flink.agents.resource.test.Mem0LongTermMemoryAgent.OLLA
* MILVUS_URI} env var
* </ul>
*/
-@Disabled("Disabled until #1087 is resolved")
public class Mem0LongTermMemoryTest {
private final boolean embeddingReady;
diff --git
a/python/flink_agents/e2e_tests/e2e_tests_resource_cross_language/concurrent_chat_model_cross_language_agent.py
b/python/flink_agents/e2e_tests/e2e_tests_resource_cross_language/concurrent_chat_model_cross_language_agent.py
new file mode 100644
index 00000000..b90b96e0
--- /dev/null
+++
b/python/flink_agents/e2e_tests/e2e_tests_resource_cross_language/concurrent_chat_model_cross_language_agent.py
@@ -0,0 +1,109 @@
+################################################################################
+# 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.
+#################################################################################
+import threading
+from typing import Any, List, Sequence
+
+from pydantic import PrivateAttr
+from typing_extensions import override
+
+from flink_agents.api.agents.agent import Agent
+from flink_agents.api.agents.types import OutputSchema
+from flink_agents.api.chat_message import ChatMessage, MessageRole
+from flink_agents.api.chat_models.chat_model import BaseChatModelConnection
+from flink_agents.api.decorators import action, chat_model_connection,
chat_model_setup
+from flink_agents.api.events.chat_event import ChatRequestEvent,
ChatResponseEvent
+from flink_agents.api.events.event import Event, InputEvent, OutputEvent
+from flink_agents.api.events.event_type import EventType
+from flink_agents.api.resource import ResourceDescriptor, ResourceName
+from flink_agents.api.runner_context import RunnerContext
+from flink_agents.api.tools.tool import Tool
+
+
+class OverlappingPythonChatModelConnection(BaseChatModelConnection):
+ """Python connection that only returns after two chat requests overlap."""
+
+ _concurrent_calls: threading.Barrier = PrivateAttr(
+ default_factory=lambda: threading.Barrier(2)
+ )
+
+ @override
+ def chat(
+ self,
+ messages: Sequence[ChatMessage],
+ tools: List[Tool] | None = None,
+ output_schema: OutputSchema | None = None,
+ **kwargs: Any,
+ ) -> ChatMessage:
+ """Echo the request after observing another in-flight call."""
+ self._reject_unsupported_output_schema(output_schema)
+ try:
+ self._concurrent_calls.wait(timeout=30)
+ except threading.BrokenBarrierError as error:
+ message = "Timed out waiting for concurrent cross-language chat
request."
+ raise RuntimeError(message) from error
+
+ return ChatMessage(
+ role=MessageRole.ASSISTANT,
+ content=f"python-connection:{messages[-1].content}",
+ )
+
+
+class ConcurrentChatModelCrossLanguageAgent(Agent):
+ """Python agent exercising concurrent Python-to-Java-to-Python chat
calls."""
+
+ @chat_model_connection
+ @staticmethod
+ def overlapping_python_connection() -> ResourceDescriptor:
+ """Declare the Python connection used behind the Java setup."""
+ return ResourceDescriptor(
+ clazz=(
+ f"{OverlappingPythonChatModelConnection.__module__}."
+ f"{OverlappingPythonChatModelConnection.__name__}"
+ )
+ )
+
+ @chat_model_setup
+ @staticmethod
+ def java_chat_model() -> ResourceDescriptor:
+ """Declare a Java setup backed by the Python connection."""
+ return ResourceDescriptor(
+ clazz=ResourceName.ChatModel.JAVA_WRAPPER_SETUP,
+ java_clazz=ResourceName.ChatModel.Java.OLLAMA_SETUP,
+ connection="overlapping_python_connection",
+ model="mock-model",
+ extract_reasoning=False,
+ )
+
+ @action(EventType.InputEvent)
+ @staticmethod
+ def request_chat(event: Event, ctx: RunnerContext) -> None:
+ """Send one chat request per input key."""
+ input_value = str(InputEvent.from_event(event).input)
+ ctx.send_event(
+ ChatRequestEvent(
+ model="java_chat_model",
+ messages=[ChatMessage(role=MessageRole.USER,
content=input_value)],
+ )
+ )
+
+ @action(EventType.ChatResponseEvent)
+ @staticmethod
+ def emit_response(event: Event, ctx: RunnerContext) -> None:
+ """Emit the cross-language chat response."""
+ response = ChatResponseEvent.from_event(event).response
+ ctx.send_event(OutputEvent(output=response.content))
diff --git
a/python/flink_agents/e2e_tests/e2e_tests_resource_cross_language/concurrent_chat_model_cross_language_test.py
b/python/flink_agents/e2e_tests/e2e_tests_resource_cross_language/concurrent_chat_model_cross_language_test.py
new file mode 100644
index 00000000..609bdd1c
--- /dev/null
+++
b/python/flink_agents/e2e_tests/e2e_tests_resource_cross_language/concurrent_chat_model_cross_language_test.py
@@ -0,0 +1,99 @@
+################################################################################
+# 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.
+#################################################################################
+import os
+import sys
+import sysconfig
+from pathlib import Path
+
+from pyflink.common import Encoder, WatermarkStrategy
+from pyflink.common.typeinfo import Types
+from pyflink.datastream import RuntimeExecutionMode, StreamExecutionEnvironment
+from pyflink.datastream.connectors.file_system import (
+ FileSource,
+ StreamFormat,
+ StreamingFileSink,
+)
+
+from flink_agents.api.core_options import AgentExecutionOptions
+from flink_agents.api.execution_environment import AgentsExecutionEnvironment
+from
flink_agents.e2e_tests.e2e_tests_resource_cross_language.concurrent_chat_model_cross_language_agent
import (
+ ConcurrentChatModelCrossLanguageAgent,
+)
+
+current_dir = Path(__file__).parent
+
+os.environ["PYTHONPATH"] = sysconfig.get_paths()["purelib"]
+
+
+def test_concurrent_java_setup_with_python_connection(tmp_path: Path) -> None:
+ """Run two overlapping chat requests through Java setup and Python
connection."""
+ env = StreamExecutionEnvironment.get_execution_environment()
+ env.set_runtime_mode(RuntimeExecutionMode.STREAMING)
+ env.set_parallelism(1)
+ env.set_python_executable(sys.executable)
+
+ input_datastream = env.from_source(
+ source=FileSource.for_record_stream_format(
+ StreamFormat.text_line_format(),
+ f"file:///{current_dir}/../resources/java_chat_module_input",
+ ).build(),
+ watermark_strategy=WatermarkStrategy.no_watermarks(),
+ source_name="concurrent_chat_inputs",
+ ).map(lambda value: str(value))
+
+ agents_env = AgentsExecutionEnvironment.get_execution_environment(env=env)
+ agents_env.get_config().set(AgentExecutionOptions.NUM_ASYNC_THREADS, 2)
+ agents_env.get_config().set(AgentExecutionOptions.CHAT_ASYNC, True)
+ output_datastream = (
+ agents_env.from_datastream(
+ input=input_datastream,
+ key_selector=lambda value: value,
+ )
+ .apply(ConcurrentChatModelCrossLanguageAgent())
+ .to_datastream()
+ )
+
+ result_dir = tmp_path / "results"
+ result_dir.mkdir(parents=True, exist_ok=True)
+ output_datastream.map(
+ lambda value: str(value).replace("\n", "").replace("\r", ""),
+ Types.STRING(),
+ ).add_sink(
+ StreamingFileSink.for_row_format(
+ base_path=str(result_dir.absolute()),
+ encoder=Encoder.simple_string_encoder(),
+ ).build()
+ )
+
+ agents_env.execute()
+
+ responses: list[str] = []
+ for file in result_dir.iterdir():
+ if file.is_dir():
+ for child in file.iterdir():
+ with child.open() as result_file:
+ responses.extend(line.strip() for line in result_file if
line.strip())
+ elif file.is_file():
+ with file.open() as result_file:
+ responses.extend(line.strip() for line in result_file if
line.strip())
+
+ assert len(responses) == 2
+ assert set(responses) == {
+ "python-connection:calculate the sum of 1 and 2.",
+ "python-connection:Tell me a joke about cats.",
+ }
diff --git a/python/flink_agents/runtime/java/java_chat_model.py
b/python/flink_agents/runtime/java/java_chat_model.py
index 15df3157..43aca50e 100644
--- a/python/flink_agents/runtime/java/java_chat_model.py
+++ b/python/flink_agents/runtime/java/java_chat_model.py
@@ -30,6 +30,21 @@ from flink_agents.api.tools.tool import Tool
from flink_agents.runtime.java.java_resource_wrapper import (
set_java_resource_metric_group,
)
+from flink_agents.runtime.python_java_utils import (
+ from_java_chat_message,
+ normalize_tool_call_id,
+)
+
+
+def _to_java_chat_message(j_resource_adapter: Any, message: ChatMessage) ->
Any:
+ """Build a Java message from fields extracted on the Python calling
thread."""
+ tool_calls = [normalize_tool_call_id(call) for call in message.tool_calls]
+ return j_resource_adapter.fromPythonChatMessage(
+ message.role.value,
+ message.content,
+ tool_calls,
+ message.extra_args,
+ )
class JavaChatModelConnectionImpl(JavaChatModelConnection):
@@ -82,7 +97,7 @@ class JavaChatModelConnectionImpl(JavaChatModelConnection):
"""
self._reject_unsupported_output_schema(output_schema)
java_messages = [
- self._j_resource_adapter.fromPythonChatMessage(message)
+ _to_java_chat_message(self._j_resource_adapter, message)
for message in messages
]
java_tools = [
@@ -91,11 +106,6 @@ class JavaChatModelConnectionImpl(JavaChatModelConnection):
]
j_response_message = self._j_resource.chat(java_messages, java_tools,
kwargs)
- # Convert Java response back to Python format
- from flink_agents.runtime.python_java_utils import (
- from_java_chat_message,
- )
-
return from_java_chat_message(j_response_message)
@override
@@ -180,16 +190,11 @@ class JavaChatModelSetupImpl(JavaChatModelSetup):
"""
# Convert Python messages to Java format
java_messages = [
- self._j_resource_adapter.fromPythonChatMessage(message)
+ _to_java_chat_message(self._j_resource_adapter, message)
for message in messages
]
j_response_message = self._j_resource.chat(
java_messages, prompt_args or {}, kwargs
)
- # Convert Java response back to Python format
- from flink_agents.runtime.python_java_utils import (
- from_java_chat_message,
- )
-
return from_java_chat_message(j_response_message)
diff --git a/python/flink_agents/runtime/tests/test_java_chat_model.py
b/python/flink_agents/runtime/tests/test_java_chat_model.py
new file mode 100644
index 00000000..87d4ae91
--- /dev/null
+++ b/python/flink_agents/runtime/tests/test_java_chat_model.py
@@ -0,0 +1,63 @@
+################################################################################
+# 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 typing import Any
+
+from flink_agents.api.chat_message import ChatMessage, MessageRole
+from flink_agents.runtime.java.java_chat_model import _to_java_chat_message
+
+
+class _JavaResourceAdapter:
+ def __init__(self) -> None:
+ self.arguments: tuple[Any, ...] | None = None
+ self.result = object()
+
+ def fromPythonChatMessage(self, *arguments: Any) -> Any:
+ self.arguments = arguments
+ return self.result
+
+
+def test_to_java_chat_message_extracts_java_safe_fields() -> None:
+ adapter = _JavaResourceAdapter()
+ message = ChatMessage(
+ role=MessageRole.ASSISTANT,
+ content="hello",
+ tool_calls=[
+ {
+ "id": 7,
+ "type": "function",
+ "function": {"name": "lookup", "arguments": "{}"},
+ }
+ ],
+ extra_args={"reasoning": "brief"},
+ )
+
+ result = _to_java_chat_message(adapter, message)
+
+ assert result is adapter.result
+ assert adapter.arguments == (
+ "assistant",
+ "hello",
+ [
+ {
+ "id": "7",
+ "type": "function",
+ "function": {"name": "lookup", "arguments": "{}"},
+ }
+ ],
+ {"reasoning": "brief"},
+ )
diff --git
a/runtime/src/main/java/org/apache/flink/agents/runtime/async/AsyncExecutorThreadFactory.java
b/runtime/src/main/java/org/apache/flink/agents/runtime/async/AsyncExecutorThreadFactory.java
index 788a9c3e..14edc1e2 100644
---
a/runtime/src/main/java/org/apache/flink/agents/runtime/async/AsyncExecutorThreadFactory.java
+++
b/runtime/src/main/java/org/apache/flink/agents/runtime/async/AsyncExecutorThreadFactory.java
@@ -17,6 +17,9 @@
*/
package org.apache.flink.agents.runtime.async;
+import java.util.Objects;
+import java.util.Queue;
+import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
@@ -35,13 +38,63 @@ import java.util.concurrent.ThreadFactory;
public final class AsyncExecutorThreadFactory implements ThreadFactory {
private static final String NAME_PREFIX = "flink-agents-java-async-";
+ private static final ThreadLocal<Boolean> ASYNC_EXECUTOR_THREAD = new
ThreadLocal<>();
private final ThreadFactory delegate = Executors.defaultThreadFactory();
+ private final Runnable threadCleanup;
+ private final Queue<Thread> createdThreads = new ConcurrentLinkedQueue<>();
+
+ public AsyncExecutorThreadFactory() {
+ this(() -> {});
+ }
+
+ public AsyncExecutorThreadFactory(Runnable threadCleanup) {
+ this.threadCleanup = Objects.requireNonNull(threadCleanup);
+ }
+
+ /** Returns whether the calling thread is owned by the Flink Agents Java
async executor. */
+ public static boolean isAsyncExecutorThread() {
+ return Boolean.TRUE.equals(ASYNC_EXECUTOR_THREAD.get());
+ }
@Override
public Thread newThread(Runnable runnable) {
- Thread thread = delegate.newThread(runnable);
+ Thread thread =
+ delegate.newThread(
+ () -> {
+ ASYNC_EXECUTOR_THREAD.set(true);
+ try {
+ runnable.run();
+ } finally {
+ try {
+ threadCleanup.run();
+ } finally {
+ ASYNC_EXECUTOR_THREAD.remove();
+ }
+ }
+ });
thread.setName(NAME_PREFIX + thread.getName());
+ createdThreads.add(thread);
return thread;
}
+
+ /** Waits until every worker created by this factory has run its exit
cleanup. */
+ public void awaitThreadExit() {
+ boolean interrupted = false;
+ try {
+ for (Thread thread : createdThreads) {
+ while (thread.isAlive()) {
+ try {
+ thread.join();
+ } catch (InterruptedException e) {
+ interrupted = true;
+ }
+ }
+ }
+ } finally {
+ if (interrupted) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ }
}
diff --git
a/runtime/src/main/java/org/apache/flink/agents/runtime/async/ContinuationActionExecutor.java
b/runtime/src/main/java/org/apache/flink/agents/runtime/async/ContinuationActionExecutor.java
index f6134da8..6a369254 100644
---
a/runtime/src/main/java/org/apache/flink/agents/runtime/async/ContinuationActionExecutor.java
+++
b/runtime/src/main/java/org/apache/flink/agents/runtime/async/ContinuationActionExecutor.java
@@ -37,6 +37,9 @@ public class ContinuationActionExecutor {
/** Creates a new ContinuationActionExecutor. */
public ContinuationActionExecutor(int numAsyncThreads) {}
+ /** JDK 11 fallback has no worker threads, so the cleanup hook is never
needed. */
+ public ContinuationActionExecutor(int numAsyncThreads, Runnable
threadCleanup) {}
+
/**
* Executes the action. In JDK 11, this simply runs the action
synchronously.
*
diff --git
a/runtime/src/main/java/org/apache/flink/agents/runtime/operator/ActionExecutionOperator.java
b/runtime/src/main/java/org/apache/flink/agents/runtime/operator/ActionExecutionOperator.java
index 1db7421b..94c050a9 100644
---
a/runtime/src/main/java/org/apache/flink/agents/runtime/operator/ActionExecutionOperator.java
+++
b/runtime/src/main/java/org/apache/flink/agents/runtime/operator/ActionExecutionOperator.java
@@ -245,7 +245,8 @@ public class ActionExecutionOperator<IN, OUT> extends
AbstractStreamOperator<OUT
// init context manager for runner context creation and memory contexts
contextManager =
new ActionTaskContextManager(
-
agentPlan.getConfig().get(AgentExecutionOptions.NUM_ASYNC_THREADS));
+
agentPlan.getConfig().get(AgentExecutionOptions.NUM_ASYNC_THREADS),
+ pythonBridge::releaseCurrentThreadInterpreter);
mailboxProcessor = getMailboxProcessor();
diff --git
a/runtime/src/main/java/org/apache/flink/agents/runtime/operator/ActionTaskContextManager.java
b/runtime/src/main/java/org/apache/flink/agents/runtime/operator/ActionTaskContextManager.java
index fb107a87..30211b11 100644
---
a/runtime/src/main/java/org/apache/flink/agents/runtime/operator/ActionTaskContextManager.java
+++
b/runtime/src/main/java/org/apache/flink/agents/runtime/operator/ActionTaskContextManager.java
@@ -77,8 +77,13 @@ class ActionTaskContextManager implements AutoCloseable {
private ContinuationActionExecutor continuationActionExecutor;
ActionTaskContextManager(int numAsyncThreads) {
+ this(numAsyncThreads, () -> {});
+ }
+
+ ActionTaskContextManager(int numAsyncThreads, Runnable asyncThreadCleanup)
{
this.actionTaskContexts = new HashMap<>();
- this.continuationActionExecutor = new
ContinuationActionExecutor(numAsyncThreads);
+ this.continuationActionExecutor =
+ new ContinuationActionExecutor(numAsyncThreads,
asyncThreadCleanup);
}
/**
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 ba5b77c7..e8b4d646 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
@@ -17,6 +17,7 @@
*/
package org.apache.flink.agents.runtime.operator;
+import org.apache.flink.agents.api.agents.AgentExecutionOptions;
import org.apache.flink.agents.api.memory.LongTermMemoryOptions;
import org.apache.flink.agents.plan.AgentPlan;
import org.apache.flink.agents.plan.JavaFunction;
@@ -32,6 +33,7 @@ import
org.apache.flink.agents.runtime.metrics.FlinkAgentsMetricGroupImpl;
import org.apache.flink.agents.runtime.python.context.PythonRunnerContextImpl;
import org.apache.flink.agents.runtime.python.utils.JavaResourceAdapter;
import org.apache.flink.agents.runtime.python.utils.PythonActionExecutor;
+import org.apache.flink.agents.runtime.python.utils.PythonInterpreterManager;
import org.apache.flink.agents.runtime.python.utils.PythonResourceAdapterImpl;
import org.apache.flink.api.common.ExecutionConfig;
import org.apache.flink.api.common.JobID;
@@ -57,7 +59,7 @@ 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 thread-confined Python interpreters obtained from that
environment.
* <li>The {@link PythonActionExecutor} (when the plan contains Python
actions, Python-owned
* resources, or Mem0).
* <li>The {@link PythonRunnerContextImpl} consumed by Python actions.
@@ -71,7 +73,7 @@ import static
org.apache.flink.agents.plan.actions.Utils.supportAsync;
* 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}.
+ * pythonInterpreterManager} → {@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
@@ -82,7 +84,8 @@ class PythonBridgeManager implements AutoCloseable {
private static final Logger LOG =
LoggerFactory.getLogger(PythonBridgeManager.class);
private PythonEnvironmentManager pythonEnvironmentManager;
- private PythonInterpreter pythonInterpreter;
+ private PythonInterpreter initializingPythonInterpreter;
+ private PythonInterpreterManager pythonInterpreterManager;
private PythonActionExecutor pythonActionExecutor;
private PythonRunnerContextImpl pythonRunnerContext;
private PythonResourceAdapterImpl pythonResourceAdapter;
@@ -100,14 +103,16 @@ class PythonBridgeManager implements AutoCloseable {
* <p>Scans the agent plan for any {@link PythonFunction} action or
Python-owned resource
* provider. If neither is present and Mem0 is not configured, this method
is a no-op and {@link
* #isInitialized()} stays {@code false}. Otherwise it builds the {@link
- * PythonEnvironmentManager}, opens an embedded {@link PythonInterpreter},
refreshes the shared
- * import state for the current dependency generation, constructs the
shared {@link
+ * PythonEnvironmentManager}, opens an owner {@link PythonInterpreter},
refreshes the shared
+ * import state for the current dependency generation, and creates a {@link
+ * PythonInterpreterManager} that binds interpreters to managed Java
workers and routes other
+ * callers through bounded callback workers. It then constructs the shared
{@link
* PythonRunnerContextImpl}, wires the Java/Python resource adapters, and
conditionally
* initializes the Python resource adapter (when Python-owned resources or
Mem0 are present) and
* the Python action executor (when Python actions, Python-owned
resources, or Mem0 are present,
* since the executor is also the bridge that materializes Python-owned
resources). The
- * generation guard runs immediately after interpreter construction and
before any user module
- * import.
+ * generation guard runs immediately after owner-interpreter construction
and before any user
+ * module import.
*
* @param agentPlan the agent plan describing actions and resources.
* @param resourceCache the resource cache visible to both languages.
@@ -157,12 +162,13 @@ class PythonBridgeManager implements AutoCloseable {
dependencyInfo, tmpDirs, new
HashMap<>(System.getenv()), jobId);
pythonEnvironmentManager.open();
EmbeddedPythonEnvironment env =
pythonEnvironmentManager.createEnvironment();
- pythonInterpreter = env.getInterpreter();
+ PythonInterpreter ownerInterpreter = env.getInterpreter();
+ initializingPythonInterpreter = ownerInterpreter;
String dependencyGeneration =
pythonEnvironmentManager.getBaseDirectory();
String pythonPath = env.getEnv().get("PYTHONPATH");
boolean dependencyGenerationChanged =
PythonDependencyGenerationManager.ensurePythonDependencyGeneration(
- pythonInterpreter,
+ ownerInterpreter,
jobId,
dependencyGeneration,
pythonPath == null ? "" : pythonPath);
@@ -172,6 +178,15 @@ class PythonBridgeManager implements AutoCloseable {
dependencyGeneration,
jobId);
}
+ // Transfer ownership only after dependency generation is active.
If generation setup
+ // fails, close() can still release initializingPythonInterpreter;
if manager
+ // construction fails, its constructor releases the owner itself.
+ initializingPythonInterpreter = null;
+ pythonInterpreterManager =
+ new PythonInterpreterManager(
+ ownerInterpreter,
+ env::getInterpreter,
+
agentPlan.getConfig().get(AgentExecutionOptions.NUM_ASYNC_THREADS));
pythonRunnerContext =
new PythonRunnerContextImpl(
metricGroup,
@@ -182,9 +197,7 @@ class PythonBridgeManager implements AutoCloseable {
javaResourceAdapter =
new JavaResourceAdapter(
- resourceCache.getResourceContext(),
- pythonInterpreter,
- userCodeClassLoader);
+ resourceCache.getResourceContext(),
userCodeClassLoader);
if (containPythonResource || mem0Configured) {
initPythonResourceAdapter(agentPlan, resourceCache);
}
@@ -242,7 +255,8 @@ class PythonBridgeManager implements AutoCloseable {
*/
private void wireLongTermMemory(AgentPlan agentPlan, Runnable
mailboxThreadChecker) {
PyObject pyCtx = pythonActionExecutor.getPythonRunnerContext();
- Object pyLtm =
pythonInterpreter.invoke("python_java_utils.get_long_term_memory", pyCtx);
+ Object pyLtm =
+
pythonInterpreterManager.invoke("python_java_utils.get_long_term_memory",
pyCtx);
if (pyLtm == null) {
throw new IllegalStateException(
String.format(
@@ -270,7 +284,7 @@ class PythonBridgeManager implements AutoCloseable {
throws Exception {
pythonActionExecutor =
new PythonActionExecutor(
- pythonInterpreter,
+ pythonInterpreterManager,
agentPlan,
javaResourceAdapter,
pythonRunnerContext,
@@ -282,7 +296,9 @@ class PythonBridgeManager implements AutoCloseable {
throws Exception {
pythonResourceAdapter =
new PythonResourceAdapterImpl(
- resourceCache.getResourceContext(), pythonInterpreter,
javaResourceAdapter);
+ resourceCache.getResourceContext(),
+ pythonInterpreterManager,
+ javaResourceAdapter);
pythonResourceAdapter.open();
PythonMCPResourceDiscovery.discoverPythonMCPResources(
agentPlan.getResourceProviders(), pythonResourceAdapter,
resourceCache);
@@ -318,6 +334,13 @@ class PythonBridgeManager implements AutoCloseable {
return initialized;
}
+ /** Releases a Python interpreter bound to the current managed Java async
worker. */
+ void releaseCurrentThreadInterpreter() {
+ if (pythonInterpreterManager != null) {
+ pythonInterpreterManager.releaseCurrentThreadInterpreter();
+ }
+ }
+
@Override
public void close() throws Exception {
// Close every component even when an earlier one fails, so a failing
action executor
@@ -333,7 +356,8 @@ class PythonBridgeManager implements AutoCloseable {
longTermMemory,
pythonActionExecutor,
pythonResourceAdapter,
- pythonInterpreter,
+ pythonInterpreterManager,
+ initializingPythonInterpreter,
pythonEnvironmentManager
}) {
if (closeable == null) {
diff --git
a/runtime/src/main/java/org/apache/flink/agents/runtime/python/utils/JavaResourceAdapter.java
b/runtime/src/main/java/org/apache/flink/agents/runtime/python/utils/JavaResourceAdapter.java
index 4a03a827..2affcf85 100644
---
a/runtime/src/main/java/org/apache/flink/agents/runtime/python/utils/JavaResourceAdapter.java
+++
b/runtime/src/main/java/org/apache/flink/agents/runtime/python/utils/JavaResourceAdapter.java
@@ -30,7 +30,6 @@ import org.apache.flink.agents.api.tools.ToolResponse;
import org.apache.flink.agents.api.vectorstores.Document;
import org.apache.flink.agents.plan.tools.FunctionTool;
import org.apache.flink.agents.plan.tools.ToolMetadataFactory;
-import pemja.core.PythonInterpreter;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
@@ -44,8 +43,6 @@ public class JavaResourceAdapter {
private final ResourceContext resourceContext;
- private final transient PythonInterpreter interpreter;
-
/**
* Class loader used to resolve Java tool methods declared by name.
Captured at construction
* (the operator passes its {@code
RuntimeContext.getUserCodeClassLoader()}) because pemja
@@ -54,12 +51,8 @@ public class JavaResourceAdapter {
*/
private final transient ClassLoader userCodeClassLoader;
- public JavaResourceAdapter(
- ResourceContext resourceContext,
- PythonInterpreter interpreter,
- ClassLoader userCodeClassLoader) {
+ public JavaResourceAdapter(ResourceContext resourceContext, ClassLoader
userCodeClassLoader) {
this.resourceContext = resourceContext;
- this.interpreter = interpreter;
this.userCodeClassLoader = userCodeClassLoader;
}
@@ -94,23 +87,23 @@ public class JavaResourceAdapter {
* Convert a Python chat message to a Java chat message. This method is
intended for use by the
* Python interpreter.
*
- * @param pythonChatMessage the Python chat message
+ * <p>The Python caller extracts the message fields before crossing into
Java. Keeping this
+ * method Java-only avoids an unnecessary Python→Java→Python callback
while constructing the
+ * Java value.
+ *
+ * @param roleValue the Python message role value
+ * @param content the message content
+ * @param toolCalls the normalized tool calls
+ * @param extraArgs additional message arguments
* @return the Java chat message
*/
- public ChatMessage fromPythonChatMessage(Object pythonChatMessage) {
+ public ChatMessage fromPythonChatMessage(
+ String roleValue,
+ String content,
+ List<Map<String, Object>> toolCalls,
+ Map<String, Object> extraArgs) {
// TODO: Delete this method after the pemja findClass method is fixed.
- ChatMessage chatMessage = new ChatMessage();
- if (interpreter == null) {
- throw new IllegalStateException("Python interpreter is not set.");
- }
- String roleValue =
- (String)
- interpreter.invoke(
- "python_java_utils.update_java_chat_message",
- pythonChatMessage,
- chatMessage);
- chatMessage.setRole(MessageRole.fromValue(roleValue));
- return chatMessage;
+ return new ChatMessage(MessageRole.fromValue(roleValue), content,
toolCalls, extraArgs);
}
/**
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 4012bd11..3f97b961 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
@@ -31,7 +31,6 @@ import
org.apache.flink.agents.runtime.python.context.PythonRunnerContextImpl;
import org.apache.flink.agents.runtime.python.resource.PythonRuntimeResource;
import org.apache.flink.types.Row;
import org.apache.flink.util.ExceptionUtils;
-import pemja.core.PythonInterpreter;
import pemja.core.object.PyObject;
import java.io.IOException;
@@ -45,11 +44,6 @@ import static org.apache.flink.util.Preconditions.checkState;
/** Execute the corresponding Python action in the agent. */
public class PythonActionExecutor implements AutoCloseable {
- private static final String PYTHON_IMPORTS =
- "from flink_agents.plan import function\n"
- + "from flink_agents.runtime import flink_runner_context\n"
- + "from flink_agents.runtime import python_java_utils";
-
// =========== RUNNER CONTEXT ===========
private static final String CREATE_FLINK_RUNNER_CONTEXT =
"flink_runner_context.create_flink_runner_context";
@@ -87,6 +81,7 @@ public class PythonActionExecutor implements AutoCloseable {
// =========== PYTHON AWAITABLE ===========
private static final String CALL_PYTHON_AWAITABLE =
"function.call_python_awaitable";
+ private static final String CALL_PYTHON_FUNCTION =
"function.call_python_function";
private static final String PYTHON_AWAITABLE_VAR_NAME_PREFIX =
"python_awaitable_";
private static final AtomicLong PYTHON_AWAITABLE_VAR_ID = new
AtomicLong(0);
@@ -101,7 +96,7 @@ public class PythonActionExecutor implements AutoCloseable {
private static final String GET_OUTPUT_FROM_OUTPUT_EVENT =
"python_java_utils.get_output_from_output_event";
- private final PythonInterpreter interpreter;
+ private final PythonInterpreterManager interpreterManager;
private final AgentPlan agentPlan;
private final PythonRunnerContextImpl runnerContext;
private final JavaResourceAdapter javaResourceAdapter;
@@ -110,13 +105,13 @@ public class PythonActionExecutor implements
AutoCloseable {
private PyObject pythonRunnerContext;
public PythonActionExecutor(
- PythonInterpreter interpreter,
+ PythonInterpreterManager interpreterManager,
AgentPlan agentPlan,
JavaResourceAdapter javaResourceAdapter,
PythonRunnerContextImpl runnerContext,
String jobIdentifier)
throws JsonProcessingException {
- this.interpreter = interpreter;
+ this.interpreterManager = interpreterManager;
this.agentPlan = agentPlan;
this.runnerContext = runnerContext;
this.javaResourceAdapter = javaResourceAdapter;
@@ -136,7 +131,7 @@ public class PythonActionExecutor implements AutoCloseable {
@SuppressWarnings("unchecked")
public Map<String, Resource> eagerMaterialize(ResourceType type) {
Object pythonResources =
- interpreter.invoke(EAGER_MATERIALIZE, pythonRunnerContext,
type.getValue());
+ interpreterManager.invoke(EAGER_MATERIALIZE,
pythonRunnerContext, type.getValue());
if (pythonResources == null) {
return Collections.emptyMap();
}
@@ -158,68 +153,66 @@ public class PythonActionExecutor implements
AutoCloseable {
*/
public boolean addTaskLifecycleListener(PyObject pythonListener) {
Object registered =
- interpreter.invoke(
+ interpreterManager.invoke(
ADD_TASK_LIFECYCLE_LISTENER, pythonRunnerContext,
pythonListener);
return Boolean.TRUE.equals(registered);
}
/** Forwards {@code onRecordStart} to the Python runtime lifecycle
listeners. */
public void notifyRecordStart(Object key) {
- interpreter.invoke(NOTIFY_RECORD_START, pythonRunnerContext, key);
+ interpreterManager.invoke(NOTIFY_RECORD_START, pythonRunnerContext,
key);
}
/** Forwards {@code onActionPrepared} to the Python runtime lifecycle
listeners. */
public void notifyActionPrepared(ActionTask task) {
- interpreter.invoke(NOTIFY_ACTION_PREPARED, pythonRunnerContext, task);
+ interpreterManager.invoke(NOTIFY_ACTION_PREPARED, pythonRunnerContext,
task);
}
/** Forwards {@code onActionStarted} to the Python runtime lifecycle
listeners. */
public void notifyActionStarted(ActionTask task) {
- interpreter.invoke(NOTIFY_ACTION_STARTED, pythonRunnerContext, task);
+ interpreterManager.invoke(NOTIFY_ACTION_STARTED, pythonRunnerContext,
task);
}
/** Forwards {@code onActionTransferred} to the Python runtime lifecycle
listeners. */
public void notifyActionTransferred(ActionTask fromTask, ActionTask
toTask) {
- interpreter.invoke(NOTIFY_ACTION_TRANSFERRED, pythonRunnerContext,
fromTask, toTask);
+ interpreterManager.invoke(NOTIFY_ACTION_TRANSFERRED,
pythonRunnerContext, fromTask, toTask);
}
/** Forwards {@code onActionFinishing} to the Python runtime lifecycle
listeners. */
public void notifyActionFinishing(ActionTask task) {
- interpreter.invoke(NOTIFY_ACTION_FINISHING, pythonRunnerContext, task);
+ interpreterManager.invoke(NOTIFY_ACTION_FINISHING,
pythonRunnerContext, task);
}
/** Forwards {@code onActionFinished} to the Python runtime lifecycle
listeners. */
public void notifyActionFinished(ActionTask task) {
- interpreter.invoke(NOTIFY_ACTION_FINISHED, pythonRunnerContext, task);
+ interpreterManager.invoke(NOTIFY_ACTION_FINISHED, pythonRunnerContext,
task);
}
/** Forwards {@code onActionReused} to the Python runtime lifecycle
listeners. */
public void notifyActionReused(ActionTask task) {
- interpreter.invoke(NOTIFY_ACTION_REUSED, pythonRunnerContext, task);
+ interpreterManager.invoke(NOTIFY_ACTION_REUSED, pythonRunnerContext,
task);
}
/** Forwards {@code onActionFailed} to the Python runtime lifecycle
listeners. */
public void notifyActionFailed(ActionTask task, Throwable error) {
- interpreter.invoke(NOTIFY_ACTION_FAILED, pythonRunnerContext, task,
error);
+ interpreterManager.invoke(NOTIFY_ACTION_FAILED, pythonRunnerContext,
task, error);
}
/** Forwards {@code onRecordFinished} to the Python runtime lifecycle
listeners. */
public void notifyRecordFinished(Object key) {
- interpreter.invoke(NOTIFY_RECORD_FINISHED, pythonRunnerContext, key);
+ interpreterManager.invoke(NOTIFY_RECORD_FINISHED, pythonRunnerContext,
key);
}
public void open() throws Exception {
- interpreter.exec(PYTHON_IMPORTS);
-
pythonAsyncThreadPool =
(PyObject)
- interpreter.invoke(
+ interpreterManager.invoke(
CREATE_ASYNC_THREAD_POOL,
agentPlan.getConfig().get(AgentExecutionOptions.NUM_ASYNC_THREADS));
pythonRunnerContext =
(PyObject)
- interpreter.invoke(
+ interpreterManager.invoke(
CREATE_FLINK_RUNNER_CONTEXT,
runnerContext,
new
ObjectMapper().writeValueAsString(agentPlan),
@@ -240,23 +233,42 @@ public class PythonActionExecutor implements
AutoCloseable {
*/
public String executePythonFunction(PythonFunction function, Event event)
throws Exception {
runnerContext.checkNoPendingEvents();
- function.setInterpreter(interpreter);
-
String eventJson = new ObjectMapper().writeValueAsString(event);
- try (PyObject pythonEventObject =
- (PyObject)
interpreter.invoke(CONVERT_JSON_TO_PYTHON_EVENT, eventJson);
- PyObject calledResult =
- (PyObject) function.call(pythonEventObject,
pythonRunnerContext)) {
- if (calledResult == null) {
- return null;
- } else {
- // must be a coroutine (awaitable)
- String pythonAwaitableRef =
- PYTHON_AWAITABLE_VAR_NAME_PREFIX
- + PYTHON_AWAITABLE_VAR_ID.incrementAndGet();
- interpreter.set(pythonAwaitableRef, calledResult);
- return pythonAwaitableRef;
- }
+ try {
+ return interpreterManager.withInterpreter(
+ interpreter -> {
+ try (PythonObjectScope scope = new
PythonObjectScope()) {
+ PyObject pythonEventObject =
+ scope.own(
+ (PyObject)
+ interpreter.invoke(
+
CONVERT_JSON_TO_PYTHON_EVENT,
+ eventJson));
+ PyObject calledResult =
+ scope.own(
+ (PyObject)
+ interpreter.invoke(
+
CALL_PYTHON_FUNCTION,
+
function.getModule(),
+
function.getQualName(),
+ new Object[] {
+
pythonEventObject,
+
pythonRunnerContext
+ }));
+ if (calledResult == null) {
+ return null;
+ }
+
+ // The result must be a coroutine (awaitable).
Keep conversion,
+ // invocation, reference retention, and temporary
reference cleanup on
+ // the same thread-confined interpreter.
+ String pythonAwaitableRef =
+ PYTHON_AWAITABLE_VAR_NAME_PREFIX
+ +
PYTHON_AWAITABLE_VAR_ID.incrementAndGet();
+ interpreter.set(pythonAwaitableRef, calledResult);
+ return pythonAwaitableRef;
+ }
+ });
} catch (Exception e) {
runnerContext.drainEvents(null);
throw new PythonActionExecutionException("Failed to execute Python
action", e);
@@ -266,7 +278,7 @@ public class PythonActionExecutor implements AutoCloseable {
public Event wrapToInputEvent(Object eventData) throws IOException {
checkState(eventData instanceof byte[]);
// wrap_to_input_event returns a JSON string
- Object result = interpreter.invoke(WRAP_TO_INPUT_EVENT, eventData);
+ Object result = interpreterManager.invoke(WRAP_TO_INPUT_EVENT,
eventData);
checkState(result instanceof String);
return Event.fromJson((String) result);
}
@@ -281,14 +293,14 @@ public class PythonActionExecutor implements
AutoCloseable {
String keySerialization =
pythonKeyIsPickled ? PICKLED_KEY_SERIALIZATION :
EXPLICIT_KEY_SERIALIZATION;
return (String)
- interpreter.invoke(
+ interpreterManager.invoke(
CONVERT_TO_PYTHON_KEY_TEXT, (byte[]) logicalKey,
keySerialization);
}
return String.valueOf(logicalKey);
}
public Object getOutputFromOutputEvent(String eventJson) {
- return interpreter.invoke(GET_OUTPUT_FROM_OUTPUT_EVENT, eventJson);
+ return interpreterManager.invoke(GET_OUTPUT_FROM_OUTPUT_EVENT,
eventJson);
}
/**
@@ -302,23 +314,32 @@ public class PythonActionExecutor implements
AutoCloseable {
* @return true if the awaitable has completed; false otherwise
*/
public boolean callPythonAwaitable(String pythonAwaitableRef) {
- try (PythonObjectScope scope = new PythonObjectScope()) {
- PyObject pythonAwaitable = scope.own((PyObject)
interpreter.get(pythonAwaitableRef));
- checkState(
- pythonAwaitable != null,
- "Python awaitable '%s' not found in interpreter.",
- pythonAwaitableRef);
- // Actions communicate through Events, so this caller consumes
only the completion flag.
- Object invokeResult =
- scope.own(interpreter.invoke(CALL_PYTHON_AWAITABLE,
pythonAwaitable));
- checkState(invokeResult instanceof Object[] && ((Object[])
invokeResult).length == 2);
- Object[] result = (Object[]) invokeResult;
- boolean finished = (boolean) result[0];
- if (finished) {
- interpreter.exec("del " + pythonAwaitableRef);
- }
- return finished;
- }
+ // Calling awaitable.send(None) in Python returns a tuple of
(finished, output).
+ return interpreterManager.withInterpreter(
+ interpreter -> {
+ try (PythonObjectScope scope = new PythonObjectScope()) {
+ PyObject pythonAwaitable =
+ scope.own((PyObject)
interpreter.get(pythonAwaitableRef));
+ checkState(
+ pythonAwaitable != null,
+ "Python awaitable '%s' not found in
interpreter.",
+ pythonAwaitableRef);
+ // Actions communicate through Events, so this caller
consumes only the
+ // completion flag.
+ Object invokeResult =
+ scope.own(
+
interpreter.invoke(CALL_PYTHON_AWAITABLE, pythonAwaitable));
+ checkState(
+ invokeResult instanceof Object[]
+ && ((Object[]) invokeResult).length ==
2);
+ Object[] result = (Object[]) invokeResult;
+ boolean finished = (boolean) result[0];
+ if (finished) {
+ interpreter.exec("del " + pythonAwaitableRef);
+ }
+ return finished;
+ }
+ });
}
@Override
@@ -328,7 +349,7 @@ public class PythonActionExecutor implements AutoCloseable {
// resource cache unreleased, and PythonBridgeManager closes the
interpreter right behind
// us, so there is no later chance to run it. The first failure is
rethrown with the later
// one suppressed, matching the ladders in the managers above.
- if (interpreter == null) {
+ if (interpreterManager == null) {
return;
}
@@ -359,7 +380,7 @@ public class PythonActionExecutor implements AutoCloseable {
private void closePythonObject(String closeFunction, PyObject
pythonObject) throws Exception {
if (pythonObject != null) {
try (pythonObject) {
- interpreter.invoke(closeFunction, pythonObject);
+ interpreterManager.invoke(closeFunction, pythonObject);
}
}
}
diff --git
a/runtime/src/main/java/org/apache/flink/agents/runtime/python/utils/PythonInterpreterManager.java
b/runtime/src/main/java/org/apache/flink/agents/runtime/python/utils/PythonInterpreterManager.java
new file mode 100644
index 00000000..c007126b
--- /dev/null
+++
b/runtime/src/main/java/org/apache/flink/agents/runtime/python/utils/PythonInterpreterManager.java
@@ -0,0 +1,409 @@
+/*
+ * 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.
+ */
+package org.apache.flink.agents.runtime.python.utils;
+
+import org.apache.flink.agents.runtime.async.AsyncExecutorThreadFactory;
+import org.apache.flink.util.ExceptionUtils;
+import pemja.core.PythonInterpreter;
+
+import java.util.ArrayList;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Queue;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.locks.ReentrantReadWriteLock;
+import java.util.function.Consumer;
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+/**
+ * Owns the Pemja interpreters used by one operator subtask.
+ *
+ * <p>A {@link PythonInterpreter} is never shared by concurrently executing
threads. The owner
+ * mailbox thread and Flink Agents' managed Java async workers bind an
interpreter to themselves.
+ * Calls from every other thread are routed to a bounded set of Java callback
workers, each of which
+ * owns its interpreter. This distinction is required for threads created by
CPython (for example
+ * Mem0 or Python's async executor): creating a Pemja interpreter there would
attach a second {@code
+ * PyThreadState} to the same native thread.
+ *
+ * <p>Pemja's {@code MULTI_THREAD} interpreters share the same CPython main
interpreter, so Python
+ * resource objects can still be initialized once and passed as opaque handles
to calls made by any
+ * of these thread-confined interpreters. Calls from one external thread
always use the same
+ * callback worker, keeping multi-step conversions involving opaque Python
handles on one
+ * interpreter. Each interpreter has its own globals, however, so the bridge
modules must be
+ * imported for every newly created interpreter.
+ */
+public final class PythonInterpreterManager implements AutoCloseable {
+
+ private static final int DEFAULT_CALLBACK_WORKERS = 2;
+ private static final AtomicInteger CALLBACK_POOL_ID = new AtomicInteger();
+
+ static final String PYTHON_IMPORTS =
+ "from flink_agents.plan import function\n"
+ + "from flink_agents.runtime import flink_runner_context\n"
+ + "from flink_agents.runtime import python_java_utils";
+
+ private final Supplier<PythonInterpreter> interpreterFactory;
+ private final Consumer<PythonInterpreter> interpreterInitializer;
+ private final Thread ownerThread;
+ private final Map<Thread, PythonInterpreter> interpreters = new
ConcurrentHashMap<>();
+ private final ThreadLocal<PythonInterpreter> threadInterpreter = new
ThreadLocal<>();
+ private final ThreadLocal<Integer> callbackLane;
+ private final ExecutorService[] callbackExecutors;
+ private final Queue<Thread> callbackThreads = new
ConcurrentLinkedQueue<>();
+ private final Queue<Throwable> workerCloseFailures = new
ConcurrentLinkedQueue<>();
+ private final ReentrantReadWriteLock lifecycleLock = new
ReentrantReadWriteLock();
+
+ private volatile boolean closed;
+
+ public PythonInterpreterManager(
+ PythonInterpreter ownerInterpreter, Supplier<PythonInterpreter>
interpreterFactory) {
+ this(
+ ownerInterpreter,
+ interpreterFactory,
+ PythonInterpreterManager::initializeInterpreter,
+ DEFAULT_CALLBACK_WORKERS);
+ }
+
+ public PythonInterpreterManager(
+ PythonInterpreter ownerInterpreter,
+ Supplier<PythonInterpreter> interpreterFactory,
+ int callbackWorkerCount) {
+ this(
+ ownerInterpreter,
+ interpreterFactory,
+ PythonInterpreterManager::initializeInterpreter,
+ callbackWorkerCount);
+ }
+
+ PythonInterpreterManager(
+ PythonInterpreter ownerInterpreter,
+ Supplier<PythonInterpreter> interpreterFactory,
+ Consumer<PythonInterpreter> interpreterInitializer) {
+ this(
+ ownerInterpreter,
+ interpreterFactory,
+ interpreterInitializer,
+ DEFAULT_CALLBACK_WORKERS);
+ }
+
+ PythonInterpreterManager(
+ PythonInterpreter ownerInterpreter,
+ Supplier<PythonInterpreter> interpreterFactory,
+ Consumer<PythonInterpreter> interpreterInitializer,
+ int callbackWorkerCount) {
+ if (callbackWorkerCount <= 0) {
+ throw new IllegalArgumentException("Callback worker count must be
greater than zero.");
+ }
+ this.interpreterFactory = Objects.requireNonNull(interpreterFactory);
+ this.interpreterInitializer =
Objects.requireNonNull(interpreterInitializer);
+ this.ownerThread = Thread.currentThread();
+
+ PythonInterpreter initializedOwner =
initialize(Objects.requireNonNull(ownerInterpreter));
+ interpreters.put(ownerThread, initializedOwner);
+ threadInterpreter.set(initializedOwner);
+ callbackExecutors = createCallbackExecutors(callbackWorkerCount);
+ AtomicInteger nextCallbackLane = new AtomicInteger();
+ callbackLane =
+ ThreadLocal.withInitial(
+ () ->
+ Math.floorMod(
+ nextCallbackLane.getAndIncrement(),
+ callbackExecutors.length));
+ }
+
+ /**
+ * Executes an operation on a thread-confined interpreter.
+ *
+ * <p>The owner and managed Java async threads execute inline. An
unmanaged caller is commonly a
+ * CPython-created thread, so it is assigned to a stable callback lane
instead of receiving a
+ * second Python thread state itself.
+ */
+ public <T> T withInterpreter(Function<PythonInterpreter, T> operation) {
+ lifecycleLock.readLock().lock();
+ try {
+ if (closed) {
+ throw new IllegalStateException("Python interpreter manager is
already closed.");
+ }
+ Function<PythonInterpreter, T> checkedOperation =
Objects.requireNonNull(operation);
+ PythonInterpreter current = threadInterpreter.get();
+ if (current != null) {
+ return checkedOperation.apply(current);
+ }
+ if (AsyncExecutorThreadFactory.isAsyncExecutorThread()) {
+ return checkedOperation.apply(createAndBindInterpreter());
+ }
+ return executeOnCallbackWorker(checkedOperation);
+ } finally {
+ lifecycleLock.readLock().unlock();
+ }
+ }
+
+ public void exec(String code) {
+ withInterpreter(
+ interpreter -> {
+ interpreter.exec(code);
+ return null;
+ });
+ }
+
+ public Object invoke(String name, Object... args) {
+ return withInterpreter(interpreter -> interpreter.invoke(name, args));
+ }
+
+ public Object get(String name) {
+ return withInterpreter(interpreter -> interpreter.get(name));
+ }
+
+ public void set(String name, Object value) {
+ withInterpreter(
+ interpreter -> {
+ interpreter.set(name, value);
+ return null;
+ });
+ }
+
+ private PythonInterpreter createAndBindInterpreter() {
+ PythonInterpreter interpreter = threadInterpreter.get();
+ if (interpreter != null) {
+ return interpreter;
+ }
+
+ PythonInterpreter created = initialize(interpreterFactory.get());
+ interpreters.put(Thread.currentThread(), created);
+ threadInterpreter.set(created);
+ return created;
+ }
+
+ private <T> T executeOnCallbackWorker(Function<PythonInterpreter, T>
operation) {
+ int lane = callbackLane.get();
+ Future<T> result =
+ callbackExecutors[lane].submit(() ->
operation.apply(createAndBindInterpreter()));
+ return awaitResult(result);
+ }
+
+ private static <T> T awaitResult(Future<T> result) {
+ boolean interrupted = false;
+ try {
+ while (true) {
+ try {
+ return result.get();
+ } catch (InterruptedException e) {
+ // A bridge invocation cannot be abandoned while close()
may reclaim its native
+ // thread state. Finish the accepted operation, then
restore interruption.
+ interrupted = true;
+ } catch (ExecutionException e) {
+ ExceptionUtils.rethrow(e.getCause());
+ throw new AssertionError("Unreachable after rethrowing
callback failure");
+ }
+ }
+ } finally {
+ if (interrupted) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ }
+
+ private PythonInterpreter initialize(PythonInterpreter interpreter) {
+ Objects.requireNonNull(interpreter);
+ try {
+ interpreterInitializer.accept(interpreter);
+ return interpreter;
+ } catch (Throwable initializationFailure) {
+ try {
+ interpreter.close();
+ } catch (Throwable closeFailure) {
+ initializationFailure.addSuppressed(closeFailure);
+ }
+ throw initializationFailure;
+ }
+ }
+
+ private static void initializeInterpreter(PythonInterpreter interpreter) {
+ interpreter.exec(PYTHON_IMPORTS);
+ }
+
+ /** Releases the interpreter owned by the current managed Java worker. */
+ public void releaseCurrentThreadInterpreter() {
+ if (!AsyncExecutorThreadFactory.isAsyncExecutorThread()) {
+ return;
+ }
+ lifecycleLock.writeLock().lock();
+ try {
+ closeCurrentInterpreterAndRecordFailure();
+ } finally {
+ lifecycleLock.writeLock().unlock();
+ }
+ }
+
+ private void closeCurrentInterpreterAndRecordFailure() {
+ Thread thread = Thread.currentThread();
+ PythonInterpreter interpreter = threadInterpreter.get();
+ threadInterpreter.remove();
+ PythonInterpreter registered = interpreters.remove(thread);
+ if (registered == null) {
+ return;
+ }
+ if (registered != interpreter) {
+ workerCloseFailures.add(
+ new IllegalStateException(
+ "The interpreter registered for worker "
+ + thread.getName()
+ + " did not match its thread-local
interpreter."));
+ }
+ try {
+ registered.close();
+ } catch (Throwable closeFailure) {
+ workerCloseFailures.add(closeFailure);
+ }
+ }
+
+ private ExecutorService[] createCallbackExecutors(int callbackWorkerCount)
{
+ int poolId = CALLBACK_POOL_ID.incrementAndGet();
+ ThreadFactory delegate = Executors.defaultThreadFactory();
+ ExecutorService[] executors = new ExecutorService[callbackWorkerCount];
+ for (int i = 0; i < callbackWorkerCount; i++) {
+ int workerId = i + 1;
+ executors[i] =
+ Executors.newSingleThreadExecutor(
+ runnable -> {
+ Thread thread =
+ delegate.newThread(
+ () -> {
+ try {
+ runnable.run();
+ } finally {
+
closeCurrentInterpreterAndRecordFailure();
+ }
+ });
+ thread.setName(
+ "flink-agents-python-callback-pool-"
+ + poolId
+ + "-thread-"
+ + workerId);
+ callbackThreads.add(thread);
+ return thread;
+ });
+ }
+ return executors;
+ }
+
+ @Override
+ public synchronized void close() throws Exception {
+ if (Thread.currentThread() != ownerThread) {
+ throw new IllegalStateException(
+ "Python interpreter manager must be closed by its owner
thread.");
+ }
+
+ lifecycleLock.writeLock().lock();
+ try {
+ if (closed) {
+ return;
+ }
+ closed = true;
+ } finally {
+ lifecycleLock.writeLock().unlock();
+ }
+
+ for (ExecutorService callbackExecutor : callbackExecutors) {
+ callbackExecutor.shutdown();
+ }
+ for (ExecutorService callbackExecutor : callbackExecutors) {
+ awaitTermination(callbackExecutor);
+ }
+ awaitThreadExit(callbackThreads);
+
+ closeCurrentInterpreterAndRecordFailure();
+ callbackLane.remove();
+
+ // Managed worker executors are closed before this manager. A dead
thread cannot retain a
+ // live GILState TLS slot, so this is only a defensive fallback for a
worker whose exit hook
+ // was unable to run. Never delete a live thread's Python state from
the owner thread.
+ for (Map.Entry<Thread, PythonInterpreter> entry :
+ new ArrayList<>(interpreters.entrySet())) {
+ Thread thread = entry.getKey();
+ if (thread.isAlive()) {
+ workerCloseFailures.add(
+ new IllegalStateException(
+ "Python interpreter worker is still alive
during manager close: "
+ + thread.getName()));
+ continue;
+ }
+ if (interpreters.remove(thread) == entry.getValue()) {
+ try {
+ entry.getValue().close();
+ } catch (Throwable closeFailure) {
+ workerCloseFailures.add(closeFailure);
+ }
+ }
+ }
+
+ Throwable firstFailure = null;
+ Throwable closeFailure;
+ while ((closeFailure = workerCloseFailures.poll()) != null) {
+ firstFailure = ExceptionUtils.firstOrSuppressed(closeFailure,
firstFailure);
+ }
+ if (firstFailure != null) {
+ ExceptionUtils.rethrowException(firstFailure);
+ }
+ }
+
+ private static void awaitTermination(ExecutorService executor) {
+ boolean interrupted = false;
+ try {
+ while (!executor.isTerminated()) {
+ try {
+ executor.awaitTermination(1, TimeUnit.DAYS);
+ } catch (InterruptedException e) {
+ interrupted = true;
+ }
+ }
+ } finally {
+ if (interrupted) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ }
+
+ private static void awaitThreadExit(Iterable<Thread> threads) {
+ boolean interrupted = false;
+ try {
+ for (Thread thread : threads) {
+ while (thread.isAlive()) {
+ try {
+ thread.join();
+ } catch (InterruptedException e) {
+ interrupted = true;
+ }
+ }
+ }
+ } finally {
+ if (interrupted) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ }
+}
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 73f206ab..ae840b3c 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
@@ -31,7 +31,6 @@ import org.apache.flink.agents.api.tools.Tool;
import org.apache.flink.agents.api.vectorstores.Document;
import org.apache.flink.agents.api.vectorstores.VectorStoreQuery;
import org.apache.flink.agents.api.vectorstores.VectorStoreQueryResult;
-import pemja.core.PythonInterpreter;
import pemja.core.object.PyObject;
import java.util.ArrayList;
@@ -41,8 +40,6 @@ import java.util.Map;
public class PythonResourceAdapterImpl implements PythonResourceAdapter,
AutoCloseable {
- static final String PYTHON_IMPORTS = "from flink_agents.runtime import
python_java_utils";
-
static final String JAVA_RESOURCE = "j_resource";
static final String JAVA_RESOURCE_ADAPTER = "j_resource_adapter";
@@ -82,22 +79,21 @@ public class PythonResourceAdapterImpl implements
PythonResourceAdapter, AutoClo
static final String INVOKE_PYTHON_TOOL = PYTHON_MODULE_PREFIX +
"invoke_python_tool";
private final ResourceContext resourceContext;
- private final PythonInterpreter interpreter;
+ private final PythonInterpreterManager interpreterManager;
private final JavaResourceAdapter javaResourceAdapter;
private PyObject pythonResourceContext;
public PythonResourceAdapterImpl(
ResourceContext resourceContext,
- PythonInterpreter interpreter,
+ PythonInterpreterManager interpreterManager,
JavaResourceAdapter javaResourceAdapter) {
this.resourceContext = resourceContext;
- this.interpreter = interpreter;
+ this.interpreterManager = interpreterManager;
this.javaResourceAdapter = javaResourceAdapter;
}
public void open() {
- interpreter.exec(PYTHON_IMPORTS);
- pythonResourceContext = (PyObject)
interpreter.invoke(GET_RESOURCE_CONTEXT, this);
+ pythonResourceContext = (PyObject)
interpreterManager.invoke(GET_RESOURCE_CONTEXT, this);
}
@Override
@@ -139,18 +135,18 @@ public class PythonResourceAdapterImpl implements
PythonResourceAdapter, AutoClo
kwargs.put(JAVA_RESOURCE, resource);
kwargs.put(JAVA_RESOURCE_ADAPTER, javaResourceAdapter);
kwargs.put(RESOURCE_CONTEXT_KEY, pythonResourceContext);
- return interpreter.invoke(FROM_JAVA_RESOURCE, resourceType, kwargs);
+ return interpreterManager.invoke(FROM_JAVA_RESOURCE, resourceType,
kwargs);
}
@Override
public PyObject initPythonResource(String module, String clazz,
Map<String, Object> kwargs) {
kwargs.put(RESOURCE_CONTEXT_KEY, pythonResourceContext);
- return (PyObject) interpreter.invoke(CREATE_RESOURCE, module, clazz,
kwargs);
+ return (PyObject) interpreterManager.invoke(CREATE_RESOURCE, module,
clazz, kwargs);
}
@Override
public Object toPythonChatMessage(ChatMessage message) {
- return interpreter.invoke(FROM_JAVA_CHAT_MESSAGE, message);
+ return interpreterManager.invoke(FROM_JAVA_CHAT_MESSAGE, message);
}
@Override
@@ -159,7 +155,9 @@ public class PythonResourceAdapterImpl implements
PythonResourceAdapter, AutoClo
ChatMessage chatMessage = new ChatMessage();
String roleValue =
- (String) interpreter.invoke(TO_JAVA_CHAT_MESSAGE,
pythonChatMessage, chatMessage);
+ (String)
+ interpreterManager.invoke(
+ TO_JAVA_CHAT_MESSAGE, pythonChatMessage,
chatMessage);
chatMessage.setRole(MessageRole.fromValue(roleValue));
return chatMessage;
}
@@ -168,7 +166,7 @@ public class PythonResourceAdapterImpl implements
PythonResourceAdapter, AutoClo
public Object toPythonDocuments(List<Document> documents) {
List<Object> pythonDocuments = new ArrayList<>();
for (Document document : documents) {
- pythonDocuments.add(interpreter.invoke(FROM_JAVA_DOCUMENT,
document));
+ pythonDocuments.add(interpreterManager.invoke(FROM_JAVA_DOCUMENT,
document));
}
return pythonDocuments;
}
@@ -189,7 +187,7 @@ public class PythonResourceAdapterImpl implements
PythonResourceAdapter, AutoClo
@Override
public Object toPythonVectorStoreQuery(VectorStoreQuery query) {
- return interpreter.invoke(FROM_JAVA_VECTOR_STORE_QUERY, query);
+ return interpreterManager.invoke(FROM_JAVA_VECTOR_STORE_QUERY, query);
}
@Override
@@ -206,26 +204,26 @@ public class PythonResourceAdapterImpl implements
PythonResourceAdapter, AutoClo
@Override
public Object convertToPythonTool(Tool tool) {
- return interpreter.invoke(FROM_JAVA_TOOL, tool);
+ return interpreterManager.invoke(FROM_JAVA_TOOL, tool);
}
private Object convertToPythonPrompt(Prompt prompt) {
- return interpreter.invoke(FROM_JAVA_PROMPT, prompt);
+ return interpreterManager.invoke(FROM_JAVA_PROMPT, prompt);
}
@Override
public Object callMethod(Object obj, String methodName, Map<String,
Object> kwargs) {
- return interpreter.invoke(CALL_METHOD, obj, methodName, kwargs);
+ return interpreterManager.invoke(CALL_METHOD, obj, methodName, kwargs);
}
@Override
public void setMetricGroup(Object pythonResource, FlinkAgentsMetricGroup
metricGroup) {
- interpreter.invoke(SET_METRIC_GROUP, pythonResource, metricGroup);
+ interpreterManager.invoke(SET_METRIC_GROUP, pythonResource,
metricGroup);
}
@Override
public Object invoke(String name, Object... args) {
- return interpreter.invoke(name, args);
+ return interpreterManager.invoke(name, args);
}
@Override
@@ -239,7 +237,7 @@ public class PythonResourceAdapterImpl implements
PythonResourceAdapter, AutoClo
@SuppressWarnings("unchecked")
Map<String, String> result =
(Map<String, String>)
- interpreter.invoke(
+ interpreterManager.invoke(
GET_PYTHON_TOOL_METADATA, module, qualName,
injectedArgs);
if (result == null) {
throw new IllegalStateException(
@@ -250,6 +248,6 @@ public class PythonResourceAdapterImpl implements
PythonResourceAdapter, AutoClo
@Override
public Object invokePythonTool(String module, String qualName, Map<String,
Object> kwargs) {
- return interpreter.invoke(INVOKE_PYTHON_TOOL, module, qualName,
kwargs);
+ return interpreterManager.invoke(INVOKE_PYTHON_TOOL, module, qualName,
kwargs);
}
}
diff --git
a/runtime/src/main/java21/org/apache/flink/agents/runtime/async/ContinuationActionExecutor.java
b/runtime/src/main/java21/org/apache/flink/agents/runtime/async/ContinuationActionExecutor.java
index 6818441b..e34649ed 100644
---
a/runtime/src/main/java21/org/apache/flink/agents/runtime/async/ContinuationActionExecutor.java
+++
b/runtime/src/main/java21/org/apache/flink/agents/runtime/async/ContinuationActionExecutor.java
@@ -49,11 +49,17 @@ public class ContinuationActionExecutor {
private static final ContinuationScope SCOPE = new
ContinuationScope("FlinkAgentsAction");
private final ExecutorService asyncExecutor;
+ private final AsyncExecutorThreadFactory asyncThreadFactory;
public ContinuationActionExecutor(int numAsyncThreads) {
+ this(numAsyncThreads, () -> {});
+ }
+
+ public ContinuationActionExecutor(int numAsyncThreads, Runnable
threadCleanup) {
LOG.info("Initialize fixed thread pool for async task with {}
threads", numAsyncThreads);
+ this.asyncThreadFactory = new
AsyncExecutorThreadFactory(threadCleanup);
this.asyncExecutor =
- Executors.newFixedThreadPool(numAsyncThreads, new
AsyncExecutorThreadFactory());
+ Executors.newFixedThreadPool(numAsyncThreads,
asyncThreadFactory);
}
/**
@@ -339,6 +345,21 @@ public class ContinuationActionExecutor {
public void close() {
asyncExecutor.shutdownNow();
+ boolean interrupted = false;
+ try {
+ while (!asyncExecutor.isTerminated()) {
+ try {
+ asyncExecutor.awaitTermination(1,
java.util.concurrent.TimeUnit.DAYS);
+ } catch (InterruptedException e) {
+ interrupted = true;
+ }
+ }
+ } finally {
+ if (interrupted) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ asyncThreadFactory.awaitThreadExit();
}
/**
diff --git
a/runtime/src/test/java/org/apache/flink/agents/runtime/async/AsyncExecutorThreadFactoryTest.java
b/runtime/src/test/java/org/apache/flink/agents/runtime/async/AsyncExecutorThreadFactoryTest.java
index 125d1812..a19b0477 100644
---
a/runtime/src/test/java/org/apache/flink/agents/runtime/async/AsyncExecutorThreadFactoryTest.java
+++
b/runtime/src/test/java/org/apache/flink/agents/runtime/async/AsyncExecutorThreadFactoryTest.java
@@ -23,6 +23,7 @@ import org.junit.jupiter.api.Test;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
@@ -30,6 +31,30 @@ import static org.assertj.core.api.Assertions.assertThat;
/** Tests for {@link AsyncExecutorThreadFactory} thread naming. */
class AsyncExecutorThreadFactoryTest {
+ @Test
+ void marksManagedWorkersAndRunsCleanupBeforeRemovingTheMarker() throws
Exception {
+ AtomicBoolean workerMarked = new AtomicBoolean();
+ AtomicBoolean cleanupMarked = new AtomicBoolean();
+ AsyncExecutorThreadFactory threadFactory =
+ new AsyncExecutorThreadFactory(
+ () ->
+ cleanupMarked.set(
+
AsyncExecutorThreadFactory.isAsyncExecutorThread()));
+ ExecutorService executor =
Executors.newSingleThreadExecutor(threadFactory);
+ try {
+ workerMarked.set(
+
executor.submit(AsyncExecutorThreadFactory::isAsyncExecutorThread).get());
+ } finally {
+ executor.shutdownNow();
+ assertThat(executor.awaitTermination(5,
TimeUnit.SECONDS)).isTrue();
+ threadFactory.awaitThreadExit();
+ }
+
+ assertThat(workerMarked.get()).isTrue();
+ assertThat(cleanupMarked.get()).isTrue();
+
assertThat(AsyncExecutorThreadFactory.isAsyncExecutorThread()).isFalse();
+ }
+
@Test
@DisplayName("Threads carry the descriptive flink-agents-java-async name")
void testThreadNamesCarryDescriptivePrefix() throws Exception {
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 fa25afff..04c4535f 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
@@ -23,12 +23,12 @@ 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.PythonInterpreterManager;
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;
import org.mockito.InOrder;
-import pemja.core.PythonInterpreter;
import java.lang.reflect.Field;
import java.util.List;
@@ -50,7 +50,7 @@ class PythonBridgeManagerTest {
Mem0LongTermMemory longTermMemory = mock(Mem0LongTermMemory.class);
PythonActionExecutor actionExecutor = mock(PythonActionExecutor.class);
PythonResourceAdapterImpl resourceAdapter =
mock(PythonResourceAdapterImpl.class);
- PythonInterpreter interpreter = mock(PythonInterpreter.class);
+ PythonInterpreterManager interpreterManager =
mock(PythonInterpreterManager.class);
PythonEnvironmentManager environmentManager =
mock(PythonEnvironmentManager.class);
RuntimeException actionExecutorFailure =
new RuntimeException("action executor close failed");
@@ -61,12 +61,12 @@ class PythonBridgeManagerTest {
RuntimeException resourceAdapterFailure =
new RuntimeException("resource adapter close failed");
doThrow(resourceAdapterFailure).when(resourceAdapter).close();
- doThrow(interpreterFailure).when(interpreter).close();
+ doThrow(interpreterFailure).when(interpreterManager).close();
doThrow(environmentFailure).when(environmentManager).close();
setField(bridge, "longTermMemory", longTermMemory);
setField(bridge, "pythonActionExecutor", actionExecutor);
setField(bridge, "pythonResourceAdapter", resourceAdapter);
- setField(bridge, "pythonInterpreter", interpreter);
+ setField(bridge, "pythonInterpreterManager", interpreterManager);
setField(bridge, "pythonEnvironmentManager", environmentManager);
assertThatThrownBy(bridge::close)
@@ -79,12 +79,12 @@ class PythonBridgeManagerTest {
longTermMemory,
actionExecutor,
resourceAdapter,
- interpreter,
+ interpreterManager,
environmentManager);
closeOrder.verify(longTermMemory).close();
closeOrder.verify(actionExecutor).close();
closeOrder.verify(resourceAdapter).close();
- closeOrder.verify(interpreter).close();
+ closeOrder.verify(interpreterManager).close();
closeOrder.verify(environmentManager).close();
}
@@ -128,14 +128,14 @@ class PythonBridgeManagerTest {
void closeReleasesInterpreterAndEnvironmentWhenActionExecutorFails()
throws Exception {
PythonBridgeManager bridge = new PythonBridgeManager();
PythonActionExecutor actionExecutor = mock(PythonActionExecutor.class);
- PythonInterpreter interpreter = mock(PythonInterpreter.class);
+ PythonInterpreterManager interpreterManager =
mock(PythonInterpreterManager.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, "pythonInterpreterManager", interpreterManager);
setField(bridge, "pythonEnvironmentManager", environmentManager);
assertThatThrownBy(bridge::close)
@@ -144,9 +144,9 @@ class PythonBridgeManagerTest {
// Contract 3: a lone failure arrives with nothing attached to
it.
.satisfies(thrown ->
assertThat(thrown.getSuppressed()).isEmpty());
- InOrder inOrder = inOrder(actionExecutor, interpreter,
environmentManager);
+ InOrder inOrder = inOrder(actionExecutor, interpreterManager,
environmentManager);
inOrder.verify(actionExecutor).close();
- inOrder.verify(interpreter).close();
+ inOrder.verify(interpreterManager).close();
inOrder.verify(environmentManager).close();
}
@@ -155,7 +155,7 @@ class PythonBridgeManagerTest {
void closeReportsFirstFailureWithLaterOnesSuppressed() throws Exception {
PythonBridgeManager bridge = new PythonBridgeManager();
PythonActionExecutor actionExecutor = mock(PythonActionExecutor.class);
- PythonInterpreter interpreter = mock(PythonInterpreter.class);
+ PythonInterpreterManager interpreterManager =
mock(PythonInterpreterManager.class);
PythonEnvironmentManager environmentManager =
mock(PythonEnvironmentManager.class);
doThrow(new IllegalStateException("action executor close failed"))
.when(actionExecutor)
@@ -165,7 +165,7 @@ class PythonBridgeManagerTest {
.close();
setField(bridge, "pythonActionExecutor", actionExecutor);
- setField(bridge, "pythonInterpreter", interpreter);
+ setField(bridge, "pythonInterpreterManager", interpreterManager);
setField(bridge, "pythonEnvironmentManager", environmentManager);
assertThatThrownBy(bridge::close)
@@ -177,7 +177,7 @@ class PythonBridgeManagerTest {
.extracting(Throwable::getMessage)
.containsExactly("environment manager
close failed"));
- verify(interpreter).close();
+ verify(interpreterManager).close();
}
/**
@@ -189,13 +189,13 @@ class PythonBridgeManagerTest {
void closeReleasesInterpreterAndEnvironmentWhenActionExecutorThrowsError()
throws Exception {
PythonBridgeManager bridge = new PythonBridgeManager();
PythonActionExecutor actionExecutor = mock(PythonActionExecutor.class);
- PythonInterpreter interpreter = mock(PythonInterpreter.class);
+ PythonInterpreterManager interpreterManager =
mock(PythonInterpreterManager.class);
PythonEnvironmentManager environmentManager =
mock(PythonEnvironmentManager.class);
OutOfMemoryError failure = new OutOfMemoryError("action executor close
failed");
doThrow(failure).when(actionExecutor).close();
setField(bridge, "pythonActionExecutor", actionExecutor);
- setField(bridge, "pythonInterpreter", interpreter);
+ setField(bridge, "pythonInterpreterManager", interpreterManager);
setField(bridge, "pythonEnvironmentManager", environmentManager);
// The Error reaches the caller unchanged rather than wrapped in an
Exception, and with
@@ -204,7 +204,7 @@ class PythonBridgeManagerTest {
.isSameAs(failure)
.satisfies(thrown ->
assertThat(thrown.getSuppressed()).isEmpty());
- verify(interpreter).close();
+ verify(interpreterManager).close();
verify(environmentManager).close();
}
diff --git
a/runtime/src/test/java/org/apache/flink/agents/runtime/python/utils/JavaResourceAdapterTest.java
b/runtime/src/test/java/org/apache/flink/agents/runtime/python/utils/JavaResourceAdapterTest.java
index dd8ddd39..a7cb735a 100644
---
a/runtime/src/test/java/org/apache/flink/agents/runtime/python/utils/JavaResourceAdapterTest.java
+++
b/runtime/src/test/java/org/apache/flink/agents/runtime/python/utils/JavaResourceAdapterTest.java
@@ -22,6 +22,8 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.flink.agents.api.annotation.Tool;
import org.apache.flink.agents.api.annotation.ToolParam;
+import org.apache.flink.agents.api.chat.messages.ChatMessage;
+import org.apache.flink.agents.api.chat.messages.MessageRole;
import org.apache.flink.agents.api.tools.ToolParameterSource;
import org.junit.jupiter.api.Test;
@@ -32,10 +34,26 @@ import static org.assertj.core.api.Assertions.assertThat;
public class JavaResourceAdapterTest {
+ @Test
+ void buildsJavaChatMessageFromExtractedPythonFields() {
+ JavaResourceAdapter adapter =
+ new JavaResourceAdapter(null,
Thread.currentThread().getContextClassLoader());
+ List<Map<String, Object>> toolCalls = List.of(Map.of("id", "call-1",
"type", "function"));
+ Map<String, Object> extraArgs = Map.of("reasoning", "brief");
+
+ ChatMessage converted =
+ adapter.fromPythonChatMessage("user", "hello", toolCalls,
extraArgs);
+
+ assertThat(converted.getRole()).isEqualTo(MessageRole.USER);
+ assertThat(converted.getContent()).isEqualTo("hello");
+ assertThat(converted.getToolCalls()).isEqualTo(toolCalls);
+ assertThat(converted.getExtraArgs()).isEqualTo(extraArgs);
+ }
+
@Test
void getJavaToolMetadataHidesInjectedArgsAndReturnsAnnotatedDeclaration()
throws Exception {
JavaResourceAdapter adapter =
- new JavaResourceAdapter(null, null,
Thread.currentThread().getContextClassLoader());
+ new JavaResourceAdapter(null,
Thread.currentThread().getContextClassLoader());
Map<String, String> metadata =
adapter.getJavaToolMetadata(
@@ -61,7 +79,7 @@ public class JavaResourceAdapterTest {
@Test
void invokeJavaToolPreservesSuccessResponseForPythonCaller() throws
Exception {
JavaResourceAdapter adapter =
- new JavaResourceAdapter(null, null,
Thread.currentThread().getContextClassLoader());
+ new JavaResourceAdapter(null,
Thread.currentThread().getContextClassLoader());
Map<String, Object> result =
adapter.invokeJavaTool(
@@ -87,7 +105,7 @@ public class JavaResourceAdapterTest {
@Test
void invokeJavaToolPreservesErrorResponseForPythonCaller() throws
Exception {
JavaResourceAdapter adapter =
- new JavaResourceAdapter(null, null,
Thread.currentThread().getContextClassLoader());
+ new JavaResourceAdapter(null,
Thread.currentThread().getContextClassLoader());
Map<String, Object> result =
adapter.invokeJavaTool(
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 b2ac5060..63575170 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
@@ -18,6 +18,7 @@
package org.apache.flink.agents.runtime.python.utils;
import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.flink.agents.api.Event;
import org.apache.flink.agents.api.InputEvent;
import org.apache.flink.agents.api.agents.AgentExecutionOptions;
import org.apache.flink.agents.plan.AgentPlan;
@@ -34,11 +35,14 @@ import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.anyString;
-import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.ArgumentMatchers.same;
import static org.mockito.Mockito.clearInvocations;
import static org.mockito.Mockito.doThrow;
@@ -63,6 +67,65 @@ class PythonActionExecutorTest {
private static final String CONVERT_JSON_TO_PYTHON_EVENT =
"python_java_utils.convert_json_to_python_event";
private static final String CALL_PYTHON_AWAITABLE =
"function.call_python_awaitable";
+ private static final String CALL_PYTHON_FUNCTION =
"function.call_python_function";
+
+ @Test
+ void
keepsActionConversionInvocationAndAwaitableOnCallingThreadsInterpreter() throws
Exception {
+ PythonInterpreter owner = mock(PythonInterpreter.class);
+ PythonInterpreter worker = mock(PythonInterpreter.class);
+ PythonInterpreterManager manager =
+ new PythonInterpreterManager(owner, () -> worker, ignored ->
{});
+ PythonRunnerContextImpl runnerContext =
mock(PythonRunnerContextImpl.class);
+ PyObject pythonRunnerContext = mock(PyObject.class);
+ PyObject pythonEvent = mock(PyObject.class);
+ PyObject createdAwaitable = mock(PyObject.class);
+ PyObject retrievedAwaitable = mock(PyObject.class);
+ PythonFunction function = new PythonFunction("test_module",
"test_action");
+ Event event = new Event("test_event");
+ String eventJson = new ObjectMapper().writeValueAsString(event);
+ ExecutorService executorService = Executors.newSingleThreadExecutor();
+ AtomicReference<String> awaitableRef = new AtomicReference<>();
+
+ when(worker.invoke("python_java_utils.convert_json_to_python_event",
eventJson))
+ .thenReturn(pythonEvent);
+ when(worker.invoke(
+ "function.call_python_function",
+ "test_module",
+ "test_action",
+ new Object[] {pythonEvent, pythonRunnerContext}))
+ .thenReturn(createdAwaitable);
+
when(worker.get(org.mockito.ArgumentMatchers.anyString())).thenReturn(retrievedAwaitable);
+ when(worker.invoke("function.call_python_awaitable",
retrievedAwaitable))
+ .thenReturn(new Object[] {false, null});
+
+ PythonActionExecutor actionExecutor =
+ new PythonActionExecutor(manager, null, null, runnerContext,
"test-job");
+ setField(actionExecutor, "pythonRunnerContext", pythonRunnerContext);
+ try {
+ boolean finished =
+ executorService
+ .submit(
+ () -> {
+ String ref =
+
actionExecutor.executePythonFunction(
+ function, event);
+ awaitableRef.set(ref);
+ return
actionExecutor.callPythonAwaitable(ref);
+ })
+ .get(5, TimeUnit.SECONDS);
+
+ assertThat(finished).isFalse();
+ assertThat(awaitableRef.get()).startsWith("python_awaitable_");
+ verify(worker).set(awaitableRef.get(), createdAwaitable);
+ verify(pythonEvent).close();
+ verify(createdAwaitable).close();
+ verify(retrievedAwaitable).close();
+ verifyNoInteractions(owner);
+ } finally {
+ executorService.shutdownNow();
+ manager.close();
+ }
+ }
@Test
void resolvesPickledPythonKeyTextFromPyFlinkKeyRow() throws Exception {
@@ -260,15 +323,19 @@ class PythonActionExecutorTest {
PythonInterpreter interpreter = mock(PythonInterpreter.class);
PythonRunnerContextImpl runnerContext =
mock(PythonRunnerContextImpl.class);
PythonActionExecutor executor = newExecutor(interpreter,
runnerContext);
- PythonFunction function = mock(PythonFunction.class);
+ PythonFunction function = new PythonFunction("test_module",
"test_action");
PyObject pythonEvent = mock(PyObject.class);
when(interpreter.invoke(same(CONVERT_JSON_TO_PYTHON_EVENT),
anyString()))
.thenReturn(pythonEvent);
- when(function.call(same(pythonEvent), isNull())).thenReturn(null);
+ when(interpreter.invoke(
+ CALL_PYTHON_FUNCTION,
+ "test_module",
+ "test_action",
+ new Object[] {pythonEvent, null}))
+ .thenReturn(null);
assertThat(executor.executePythonFunction(function, new
InputEvent(1L))).isNull();
- verify(function).setInterpreter(interpreter);
verify(pythonEvent).close();
}
@@ -277,12 +344,17 @@ class PythonActionExecutorTest {
PythonInterpreter interpreter = mock(PythonInterpreter.class);
PythonRunnerContextImpl runnerContext =
mock(PythonRunnerContextImpl.class);
PythonActionExecutor executor = newExecutor(interpreter,
runnerContext);
- PythonFunction function = mock(PythonFunction.class);
+ PythonFunction function = new PythonFunction("test_module",
"test_action");
PyObject pythonEvent = mock(PyObject.class);
PyObject pythonAwaitable = mock(PyObject.class);
when(interpreter.invoke(same(CONVERT_JSON_TO_PYTHON_EVENT),
anyString()))
.thenReturn(pythonEvent);
- when(function.call(same(pythonEvent),
isNull())).thenReturn(pythonAwaitable);
+ when(interpreter.invoke(
+ CALL_PYTHON_FUNCTION,
+ "test_module",
+ "test_action",
+ new Object[] {pythonEvent, null}))
+ .thenReturn(pythonAwaitable);
String pythonAwaitableRef = executor.executePythonFunction(function,
new InputEvent(1L));
@@ -291,10 +363,12 @@ class PythonActionExecutorTest {
assertThat(pythonAwaitableRef)
.isEqualTo(refCaptor.getValue())
.startsWith("python_awaitable_");
- InOrder closeOrder = inOrder(interpreter, pythonAwaitable,
pythonEvent);
- closeOrder.verify(interpreter).set(pythonAwaitableRef,
pythonAwaitable);
- closeOrder.verify(pythonAwaitable).close();
- closeOrder.verify(pythonEvent).close();
+ InOrder awaitableCloseOrder = inOrder(interpreter, pythonAwaitable);
+ awaitableCloseOrder.verify(interpreter).set(pythonAwaitableRef,
pythonAwaitable);
+ awaitableCloseOrder.verify(pythonAwaitable).close();
+ InOrder eventCloseOrder = inOrder(interpreter, pythonEvent);
+ eventCloseOrder.verify(interpreter).set(pythonAwaitableRef,
pythonAwaitable);
+ eventCloseOrder.verify(pythonEvent).close();
}
@Test
@@ -343,7 +417,8 @@ class PythonActionExecutorTest {
private static PythonActionExecutor newExecutor(
PythonInterpreter interpreter, PythonRunnerContextImpl
runnerContext) throws Exception {
- return new PythonActionExecutor(interpreter, null, null,
runnerContext, "test-job");
+ return new PythonActionExecutor(
+ newInterpreterManager(interpreter), null, null, runnerContext,
"test-job");
}
private static TestFixture createOpenedExecutor() throws Exception {
@@ -371,11 +446,24 @@ class PythonActionExecutorTest {
PythonActionExecutor executor =
new PythonActionExecutor(
- interpreter, plan, resourceAdapter, runnerContext,
jobIdentifier);
+ newInterpreterManager(interpreter),
+ plan,
+ resourceAdapter,
+ runnerContext,
+ jobIdentifier);
executor.open();
return new TestFixture(interpreter, asyncThreadPool,
runnerContextObject, executor);
}
+ private static PythonInterpreterManager
newInterpreterManager(PythonInterpreter interpreter) {
+ return new PythonInterpreterManager(
+ interpreter,
+ () -> {
+ throw new AssertionError("unexpected worker interpreter");
+ },
+ ignored -> {});
+ }
+
private static final class TestFixture {
private final PythonInterpreter interpreter;
private final PyObject asyncThreadPool;
diff --git
a/runtime/src/test/java/org/apache/flink/agents/runtime/python/utils/PythonInterpreterManagerTest.java
b/runtime/src/test/java/org/apache/flink/agents/runtime/python/utils/PythonInterpreterManagerTest.java
new file mode 100644
index 00000000..5df8d9f2
--- /dev/null
+++
b/runtime/src/test/java/org/apache/flink/agents/runtime/python/utils/PythonInterpreterManagerTest.java
@@ -0,0 +1,414 @@
+/*
+ * 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.
+ */
+package org.apache.flink.agents.runtime.python.utils;
+
+import org.apache.flink.agents.runtime.async.AsyncExecutorThreadFactory;
+import org.junit.jupiter.api.Test;
+import pemja.core.PythonInterpreter;
+
+import java.util.Queue;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+
+/** Defect-oriented concurrency tests for {@link PythonInterpreterManager}. */
+class PythonInterpreterManagerTest {
+
+ @Test
+ void routesUnmanagedThreadCallsAwayFromTheCallerThread() throws Exception {
+ PythonInterpreter owner = mock(PythonInterpreter.class);
+ AtomicReference<Thread> callerThread = new AtomicReference<>();
+ AtomicReference<Thread> interpreterCreationThread = new
AtomicReference<>();
+ ExecutorService executor = Executors.newSingleThreadExecutor();
+
+ try (PythonInterpreterManager manager =
+ new PythonInterpreterManager(
+ owner,
+ () -> {
+
interpreterCreationThread.set(Thread.currentThread());
+ return mock(PythonInterpreter.class);
+ },
+ ignored -> {})) {
+ executor.submit(
+ () -> {
+ callerThread.set(Thread.currentThread());
+ manager.invoke("callback");
+ })
+ .get(5, TimeUnit.SECONDS);
+
+ assertThat(interpreterCreationThread.get())
+ .as("an unmanaged caller must not create a Pemja thread
state on itself")
+ .isNotSameAs(callerThread.get());
+ } finally {
+ executor.shutdownNow();
+ }
+ }
+
+ @Test
+ void boundsInterpretersCreatedForTransientUnmanagedThreads() throws
Exception {
+ PythonInterpreter owner = mock(PythonInterpreter.class);
+ AtomicInteger created = new AtomicInteger();
+ AtomicReference<Throwable> failure = new AtomicReference<>();
+
+ try (PythonInterpreterManager manager =
+ new PythonInterpreterManager(
+ owner,
+ () -> {
+ created.incrementAndGet();
+ return mock(PythonInterpreter.class);
+ },
+ ignored -> {})) {
+ for (int i = 0; i < 20; i++) {
+ Thread caller =
+ new Thread(
+ () -> {
+ try {
+ manager.invoke("callback");
+ } catch (Throwable t) {
+ failure.set(t);
+ }
+ });
+ caller.start();
+ caller.join(5000);
+ assertThat(caller.isAlive()).isFalse();
+ }
+
+ assertThat(failure.get()).isNull();
+ assertThat(created.get())
+ .as("transient Python-originated callers must reuse
bounded callback workers")
+ .isBetween(1, 2);
+ }
+ }
+
+ @Test
+ void managedJavaAsyncWorkerCreatesUsesAndClosesInterpreterOnItself()
throws Exception {
+ PythonInterpreter owner = mock(PythonInterpreter.class);
+ PythonInterpreter worker = mock(PythonInterpreter.class);
+ AtomicReference<Thread> creationThread = new AtomicReference<>();
+ AtomicReference<Thread> invocationThread = new AtomicReference<>();
+ AtomicReference<Thread> closeThread = new AtomicReference<>();
+
+ doAnswer(
+ invocation -> {
+ invocationThread.set(Thread.currentThread());
+ return null;
+ })
+ .when(worker)
+ .invoke("worker-call");
+ doAnswer(
+ invocation -> {
+ closeThread.set(Thread.currentThread());
+ return null;
+ })
+ .when(worker)
+ .close();
+
+ try (PythonInterpreterManager manager =
+ new PythonInterpreterManager(
+ owner,
+ () -> {
+ creationThread.set(Thread.currentThread());
+ return worker;
+ },
+ ignored -> {})) {
+ AsyncExecutorThreadFactory threadFactory =
+ new
AsyncExecutorThreadFactory(manager::releaseCurrentThreadInterpreter);
+ ExecutorService executor =
Executors.newSingleThreadExecutor(threadFactory);
+ try {
+ executor.submit(() -> manager.invoke("worker-call")).get(5,
TimeUnit.SECONDS);
+ } finally {
+ executor.shutdownNow();
+ assertThat(executor.awaitTermination(5,
TimeUnit.SECONDS)).isTrue();
+ threadFactory.awaitThreadExit();
+ }
+
+ assertThat(invocationThread.get()).isSameAs(creationThread.get());
+ verify(worker).close();
+ assertThat(closeThread.get()).isSameAs(creationThread.get());
+ }
+ }
+
+ @Test
+ void callbackInterpreterIsClosedByItsOwningWorker() throws Exception {
+ PythonInterpreter owner = mock(PythonInterpreter.class);
+ PythonInterpreter callback = mock(PythonInterpreter.class);
+ AtomicReference<Thread> creationThread = new AtomicReference<>();
+ AtomicReference<Thread> closeThread = new AtomicReference<>();
+ ExecutorService caller = Executors.newSingleThreadExecutor();
+
+ doAnswer(
+ invocation -> {
+ closeThread.set(Thread.currentThread());
+ return null;
+ })
+ .when(callback)
+ .close();
+
+ PythonInterpreterManager manager =
+ new PythonInterpreterManager(
+ owner,
+ () -> {
+ creationThread.set(Thread.currentThread());
+ return callback;
+ },
+ ignored -> {});
+ try {
+ caller.submit(() -> manager.invoke("callback")).get(5,
TimeUnit.SECONDS);
+ manager.close();
+
+ verify(callback).close();
+ assertThat(closeThread.get()).isSameAs(creationThread.get());
+ } finally {
+ manager.close();
+ caller.shutdownNow();
+ }
+ }
+
+ @Test
+ void rejectsCloseFromANonOwnerThread() throws Exception {
+ PythonInterpreter owner = mock(PythonInterpreter.class);
+ ExecutorService caller = Executors.newSingleThreadExecutor();
+
+ try (PythonInterpreterManager manager =
+ new PythonInterpreterManager(owner, () ->
mock(PythonInterpreter.class))) {
+ Future<?> close =
+ caller.submit(
+ () -> {
+ manager.close();
+ return null;
+ });
+
+ assertThatThrownBy(() -> close.get(5, TimeUnit.SECONDS))
+ .hasCauseInstanceOf(IllegalStateException.class)
+ .hasRootCauseMessage(
+ "Python interpreter manager must be closed by its
owner thread.");
+ assertThatCode(() ->
manager.invoke("still-open")).doesNotThrowAnyException();
+ } finally {
+ caller.shutdownNow();
+ }
+ }
+
+ @Test
+ void reusesOwnerInterpreterOnCreatingThread() throws Exception {
+ PythonInterpreter owner = mock(PythonInterpreter.class);
+ PythonInterpreter unused = mock(PythonInterpreter.class);
+
+ try (PythonInterpreterManager manager =
+ new PythonInterpreterManager(owner, () -> unused, ignored ->
{})) {
+ manager.invoke("first");
+ manager.invoke("second");
+
+ verify(owner).invoke("first");
+ verify(owner).invoke("second");
+ verify(unused, never()).invoke("first");
+ verify(unused, never()).invoke("second");
+ }
+ }
+
+ @Test
+ void initializesEveryThreadConfinedInterpreter() throws Exception {
+ PythonInterpreter owner = mock(PythonInterpreter.class);
+ PythonInterpreter worker = mock(PythonInterpreter.class);
+ ExecutorService executor = Executors.newSingleThreadExecutor();
+
+ try (PythonInterpreterManager manager = new
PythonInterpreterManager(owner, () -> worker)) {
+ executor.submit(() -> manager.invoke("worker-call")).get(5,
TimeUnit.SECONDS);
+
+ verify(owner).exec(PythonInterpreterManager.PYTHON_IMPORTS);
+ verify(worker).exec(PythonInterpreterManager.PYTHON_IMPORTS);
+ verify(worker).invoke("worker-call");
+ } finally {
+ executor.shutdownNow();
+ }
+ }
+
+ @Test
+ void bindsDifferentInterpretersToDifferentThreads() throws Exception {
+ PythonInterpreter owner = mock(PythonInterpreter.class);
+ Queue<PythonInterpreter> created = new ConcurrentLinkedQueue<>();
+ ExecutorService executor = Executors.newFixedThreadPool(2);
+
+ try (PythonInterpreterManager manager =
+ new PythonInterpreterManager(
+ owner,
+ () -> {
+ PythonInterpreter interpreter =
mock(PythonInterpreter.class);
+ created.add(interpreter);
+ return interpreter;
+ },
+ ignored -> {})) {
+ CountDownLatch ready = new CountDownLatch(2);
+ CountDownLatch release = new CountDownLatch(1);
+ Future<PythonInterpreter> first =
+ executor.submit(
+ () ->
+ manager.withInterpreter(
+ interpreter -> {
+ ready.countDown();
+ await(release);
+ PythonInterpreter nested =
+
manager.withInterpreter(value -> value);
+
assertThat(nested).isSameAs(interpreter);
+ return interpreter;
+ }));
+ Future<PythonInterpreter> second =
+ executor.submit(
+ () ->
+ manager.withInterpreter(
+ interpreter -> {
+ ready.countDown();
+ await(release);
+ PythonInterpreter nested =
+
manager.withInterpreter(value -> value);
+
assertThat(nested).isSameAs(interpreter);
+ return interpreter;
+ }));
+
+ assertThat(ready.await(5, TimeUnit.SECONDS)).isTrue();
+ release.countDown();
+
+ assertThat(first.get(5,
TimeUnit.SECONDS)).isNotSameAs(second.get(5, TimeUnit.SECONDS));
+ assertThat(created).hasSize(2);
+ } finally {
+ executor.shutdownNow();
+ }
+ }
+
+ @Test
+ void doesNotSerializeCallsMadeByDifferentThreads() throws Exception {
+ PythonInterpreter owner = mock(PythonInterpreter.class);
+ CountDownLatch entered = new CountDownLatch(2);
+ CountDownLatch release = new CountDownLatch(1);
+ ExecutorService executor = Executors.newFixedThreadPool(2);
+
+ try (PythonInterpreterManager manager =
+ new PythonInterpreterManager(
+ owner,
+ () -> {
+ PythonInterpreter interpreter =
mock(PythonInterpreter.class);
+ doAnswer(
+ invocation -> {
+ entered.countDown();
+ await(release);
+ return null;
+ })
+ .when(interpreter)
+ .invoke("blocking");
+ return interpreter;
+ },
+ ignored -> {})) {
+ Future<?> first = executor.submit(() ->
manager.invoke("blocking"));
+ Future<?> second = executor.submit(() ->
manager.invoke("blocking"));
+
+ assertThat(entered.await(5, TimeUnit.SECONDS))
+ .as("both interpreter calls should overlap")
+ .isTrue();
+ release.countDown();
+ first.get(5, TimeUnit.SECONDS);
+ second.get(5, TimeUnit.SECONDS);
+ } finally {
+ release.countDown();
+ executor.shutdownNow();
+ }
+ }
+
+ @Test
+ void reentrantCallUsesSameInterpreter() throws Exception {
+ PythonInterpreter owner = mock(PythonInterpreter.class);
+ AtomicReference<PythonInterpreterManager> managerRef = new
AtomicReference<>();
+ doAnswer(invocation ->
managerRef.get().invoke("inner")).when(owner).invoke("outer");
+
+ try (PythonInterpreterManager manager =
+ new PythonInterpreterManager(
+ owner,
+ () -> {
+ throw new AssertionError("reentrant call created
another interpreter");
+ },
+ ignored -> {})) {
+ managerRef.set(manager);
+
+ assertThatCode(() ->
manager.invoke("outer")).doesNotThrowAnyException();
+ verify(owner).invoke("inner");
+ }
+ }
+
+ @Test
+ void reentrantCallbackCallUsesSameInterpreter() throws Exception {
+ PythonInterpreter owner = mock(PythonInterpreter.class);
+ PythonInterpreter callback = mock(PythonInterpreter.class);
+ AtomicReference<PythonInterpreterManager> managerRef = new
AtomicReference<>();
+ ExecutorService caller = Executors.newSingleThreadExecutor();
+ doAnswer(invocation ->
managerRef.get().invoke("inner")).when(callback).invoke("outer");
+
+ try (PythonInterpreterManager manager =
+ new PythonInterpreterManager(owner, () -> callback, ignored ->
{})) {
+ managerRef.set(manager);
+
+ caller.submit(() -> manager.invoke("outer")).get(5,
TimeUnit.SECONDS);
+ verify(callback).invoke("inner");
+ verify(owner, never()).invoke("inner");
+ } finally {
+ caller.shutdownNow();
+ }
+ }
+
+ @Test
+ void closesEveryInterpreterAndRejectsLaterCalls() throws Exception {
+ PythonInterpreter owner = mock(PythonInterpreter.class);
+ PythonInterpreter worker = mock(PythonInterpreter.class);
+ ExecutorService executor = Executors.newSingleThreadExecutor();
+ PythonInterpreterManager manager =
+ new PythonInterpreterManager(owner, () -> worker, ignored ->
{});
+
+ try {
+ executor.submit(() -> manager.invoke("worker-call")).get(5,
TimeUnit.SECONDS);
+ manager.close();
+
+ verify(owner).close();
+ verify(worker).close();
+ assertThatThrownBy(() -> manager.invoke("after-close"))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("already closed");
+ } finally {
+ manager.close();
+ executor.shutdownNow();
+ }
+ }
+
+ private static void await(CountDownLatch latch) {
+ try {
+ assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new AssertionError(e);
+ }
+ }
+}
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 b2225fdb..41bf28ed 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
@@ -47,18 +47,27 @@ public class PythonResourceAdapterImplTest {
@Mock private ResourceContext resourceContext;
private PythonResourceAdapterImpl pythonResourceAdapter;
+ private PythonInterpreterManager interpreterManager;
private AutoCloseable mocks;
@BeforeEach
void setUp() throws Exception {
mocks = MockitoAnnotations.openMocks(this);
+ interpreterManager =
+ new PythonInterpreterManager(
+ mockInterpreter,
+ () -> {
+ throw new AssertionError("unexpected worker
interpreter");
+ },
+ ignored -> {});
pythonResourceAdapter =
- new PythonResourceAdapterImpl(resourceContext,
mockInterpreter, null);
+ new PythonResourceAdapterImpl(resourceContext,
interpreterManager, null);
}
@AfterEach
void tearDown() throws Exception {
if (mocks != null) {
+ interpreterManager.close();
mocks.close();
}
}
@@ -89,7 +98,6 @@ public class PythonResourceAdapterImplTest {
pythonResourceAdapter.open();
- verify(mockInterpreter).exec(PythonResourceAdapterImpl.PYTHON_IMPORTS);
verify(mockInterpreter)
.invoke(PythonResourceAdapterImpl.GET_RESOURCE_CONTEXT,
pythonResourceAdapter);
}