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 12262f63 [runtime][python] Refresh imports for new dependency 
generations (#943)
12262f63 is described below

commit 12262f63bd52a872dfca26ead964a98ce64274e8
Author: Yu Tang <[email protected]>
AuthorDate: Mon Aug 24 17:31:21 2026 +0800

    [runtime][python] Refresh imports for new dependency generations (#943)
    
    Co-authored-by: Codex <[email protected]>
    Co-authored-by: Cursor <[email protected]>
    Co-authored-by: Claude Code <[email protected]>
---
 python/flink_agents/runtime/_python_dependency.py  | 229 ++++++++++++++++
 .../runtime/tests/test_python_dependency.py        | 295 +++++++++++++++++++++
 .../runtime/operator/PythonBridgeManager.java      |  22 +-
 .../PythonDependencyGenerationManager.java         |  73 +++++
 .../PythonDependencyGenerationManagerTest.java     |  84 ++++++
 5 files changed, 700 insertions(+), 3 deletions(-)

diff --git a/python/flink_agents/runtime/_python_dependency.py 
b/python/flink_agents/runtime/_python_dependency.py
new file mode 100644
index 00000000..5fe65e00
--- /dev/null
+++ b/python/flink_agents/runtime/_python_dependency.py
@@ -0,0 +1,229 @@
+################################################################################
+#  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 __future__ import annotations
+
+import importlib
+import os
+import sys
+import threading
+from pathlib import Path
+from types import ModuleType
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+    from collections.abc import Iterator
+    from typing import Any
+
+# Interpreter-scoped generation records. This helper may be imported from a
+# job's python-dist directory and evicted when that directory is replaced; the
+# records must outlive any one generation-scoped module.
+_STATE_MODULE_NAME = "_flink_agents_python_dependency_state"
+
+
+def _get_state_module() -> ModuleType:
+    state = sys.modules.get(_STATE_MODULE_NAME)
+    if state is not None:
+        return state
+
+    candidate = ModuleType(_STATE_MODULE_NAME)
+    candidate.generation_lock = threading.RLock()
+    candidate.job_generations = {}
+    # setdefault prevents concurrent Pemja threads from installing different
+    # state modules.
+    return sys.modules.setdefault(_STATE_MODULE_NAME, candidate)
+
+
+_STATE = _get_state_module()
+_GENERATION_LOCK = _STATE.generation_lock
+_JOB_GENERATIONS = _STATE.job_generations
+
+
+def ensure_python_dependency_generation(
+    job_id: str, generation: str, python_path: str = ""
+) -> bool:
+    """Activate a Flink-managed dependency generation in the Pemja interpreter.
+
+    When Flink replaces a job's temporary dependency directory, remove imports
+    owned by the previous directory before user actions or resources are 
loaded.
+
+    This tracks one generation per job id and only refreshes that job's 
previous
+    directory. It does not deactivate generations when a job ends, and it does
+    not isolate import caches across jobs that share a TaskManager.
+
+    ``python_path`` is the generation's configured ``PYTHONPATH``. Activation
+    prepends those entries even if they are not already on ``sys.path``. 
Callers
+    must invoke this after the interpreter is constructed and before any user
+    module is imported.
+
+    Returns:
+        ``True`` when a different generation was activated, otherwise 
``False``.
+    """
+    if not job_id:
+        msg = "job_id must not be empty"
+        raise ValueError(msg)
+
+    current_generation = _normalize_path(generation)
+    if not Path(current_generation).is_dir():
+        msg = f"Python dependency generation does not exist: 
{current_generation}"
+        raise RuntimeError(msg)
+
+    with _GENERATION_LOCK:
+        previous_generation = _JOB_GENERATIONS.get(job_id)
+        if previous_generation == current_generation:
+            # Pemja inserts configured paths for every interpreter sharing this
+            # generation.
+            _deduplicate_and_prepend_paths(
+                _configured_paths_for_generation(current_generation, 
python_path)
+            )
+            return False
+
+        if previous_generation is not None:
+            _deactivate_generation(previous_generation)
+
+        _activate_generation(current_generation, python_path)
+
+        _JOB_GENERATIONS[job_id] = current_generation
+        return True
+
+
+def _normalize_path(path: str | os.PathLike[str]) -> str:
+    # Keep Flink's symlink path so imported modules remain attributable to
+    # their owning python-dist generation.
+    return os.path.normcase(str(Path(path).absolute()))
+
+
+def _deactivate_generation(generation: str) -> None:
+    _clear_python_function_cache()
+    _evict_modules_from_generation(generation)
+    _remove_paths_from_generation(sys.path, generation)
+    _clear_importer_cache(generation)
+
+
+def _activate_generation(generation: str, python_path: str = "") -> None:
+    _deduplicate_and_prepend_paths(
+        _configured_paths_for_generation(generation, python_path)
+    )
+    _clear_importer_cache(generation)
+    importlib.invalidate_caches()
+
+
+def _python_path_entries(python_path: str) -> list[str]:
+    if not python_path:
+        return []
+    return [entry for entry in python_path.split(os.pathsep) if entry]
+
+
+def _configured_paths_for_generation(generation: str, python_path: str) -> 
list[str]:
+    return _paths_for_generation(
+        [*_python_path_entries(python_path), *sys.path], generation
+    )
+
+
+def _paths_for_generation(paths: list[str], generation: str) -> list[str]:
+    paths = (_try_normalize_path(path) for path in paths)
+    return list(
+        dict.fromkeys(
+            path
+            for path in paths
+            if path is not None and _path_belongs_to_generation(path, 
generation)
+        )
+    )
+
+
+def _module_paths(module: ModuleType) -> Iterator[Any]:
+    spec = getattr(module, "__spec__", None)
+    path_values = (
+        getattr(module, "__file__", None),
+        getattr(module, "__path__", None),
+        getattr(spec, "origin", None),
+        getattr(spec, "submodule_search_locations", None),
+    )
+    for value in path_values:
+        if isinstance(value, str | bytes | os.PathLike):
+            yield value
+        elif value is not None:
+            try:
+                yield from value
+            except (TypeError, ValueError):
+                continue
+
+
+def _try_normalize_path(path: Any) -> str | None:
+    if not isinstance(path, str | bytes | os.PathLike):
+        return None
+    try:
+        return _normalize_path(os.fsdecode(path))
+    except (OSError, TypeError, ValueError):
+        return None
+
+
+def _clear_python_function_cache() -> None:
+    # Same-job failover runs this during operator open after the previous
+    # attempt has closed, so no concurrent call_python_function is expected.
+    function_module = sys.modules.get("flink_agents.plan.function")
+    if function_module is not None:
+        function_module.clear_python_function_cache()
+
+
+def _evict_modules_from_generation(generation: str) -> None:
+    modules_to_remove = []
+    for module_name, module in list(sys.modules.items()):
+        if module_name == _STATE_MODULE_NAME:
+            continue
+        if module is not None and any(
+            _path_belongs_to_generation(path, generation)
+            for path in _module_paths(module)
+        ):
+            modules_to_remove.append(module_name)
+
+    for module_name in sorted(
+        modules_to_remove, key=lambda name: name.count("."), reverse=True
+    ):
+        sys.modules.pop(module_name, None)
+
+
+def _remove_paths_from_generation(paths: list[str], generation: str) -> None:
+    paths[:] = [
+        path for path in paths if not _path_belongs_to_generation(path, 
generation)
+    ]
+
+
+def _deduplicate_and_prepend_paths(current_paths: list[str]) -> None:
+    normalized_current_paths = set(current_paths)
+    sys.path[:] = [
+        path
+        for path in sys.path
+        if _try_normalize_path(path) not in normalized_current_paths
+    ]
+    sys.path[0:0] = current_paths
+
+
+def _clear_importer_cache(generation: str) -> None:
+    for path in list(sys.path_importer_cache):
+        if _path_belongs_to_generation(path, generation):
+            sys.path_importer_cache.pop(path, None)
+
+
+def _path_belongs_to_generation(path: Any, generation: str) -> bool:
+    normalized = _try_normalize_path(path)
+    if normalized is None:
+        return False
+    candidate = Path(normalized)
+    generation_path = Path(generation)
+    return candidate == generation_path or generation_path in candidate.parents
diff --git a/python/flink_agents/runtime/tests/test_python_dependency.py 
b/python/flink_agents/runtime/tests/test_python_dependency.py
new file mode 100644
index 00000000..2b96d559
--- /dev/null
+++ b/python/flink_agents/runtime/tests/test_python_dependency.py
@@ -0,0 +1,295 @@
+################################################################################
+#  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 importlib
+import importlib.util
+import shutil
+import sys
+import uuid
+from importlib.resources import files
+from pathlib import Path
+from types import ModuleType
+
+import pytest
+
+from flink_agents.plan import function as plan_function
+from flink_agents.runtime import _python_dependency
+
+_HELPER_MODULE = "flink_agents.runtime._python_dependency"
+
+
[email protected](autouse=True)
+def restore_import_state():
+    original_sys_path = list(sys.path)
+    original_importer_cache = dict(sys.path_importer_cache)
+    original_generations = dict(_python_dependency._JOB_GENERATIONS)
+    original_function_cache = dict(plan_function._PYTHON_FUNCTION_CACHE)
+    original_helper = sys.modules.get(_HELPER_MODULE)
+    imported_packages: set[str] = set()
+
+    yield imported_packages
+
+    sys.path[:] = original_sys_path
+    sys.path_importer_cache.clear()
+    sys.path_importer_cache.update(original_importer_cache)
+    _python_dependency._JOB_GENERATIONS.clear()
+    _python_dependency._JOB_GENERATIONS.update(original_generations)
+    plan_function._PYTHON_FUNCTION_CACHE.clear()
+    plan_function._PYTHON_FUNCTION_CACHE.update(original_function_cache)
+    if original_helper is not None:
+        sys.modules[_HELPER_MODULE] = original_helper
+    for package_name in imported_packages:
+        for module_name in list(sys.modules):
+            if module_name == package_name or module_name.startswith(
+                f"{package_name}."
+            ):
+                sys.modules.pop(module_name, None)
+    importlib.invalidate_caches()
+
+
+def test_generation_change_reloads_user_package_and_resources(
+    tmp_path: Path, restore_import_state
+):
+    package_name = f"generation_package_{uuid.uuid4().hex}"
+    restore_import_state.add(package_name)
+    old_generation, old_python_path = _create_generation(
+        tmp_path, "old", package_name, "old"
+    )
+    sys.path.insert(0, str(old_python_path))
+
+    assert _python_dependency.ensure_python_dependency_generation(
+        "job-1", str(old_generation)
+    )
+    old_module = importlib.import_module(f"{package_name}.action")
+    assert old_module.VALUE == "old"
+    plan_function._PYTHON_FUNCTION_CACHE[(f"{package_name}.action", 
"handler")] = (
+        object()
+    )
+    assert files(package_name).joinpath("skills", "SKILL.md").read_text() == 
"old"
+
+    shutil.rmtree(old_generation)
+    new_generation, new_python_path = _create_generation(
+        tmp_path, "new", package_name, "new"
+    )
+    sys.path.insert(0, str(new_python_path))
+
+    assert _python_dependency.ensure_python_dependency_generation(
+        "job-1", str(new_generation)
+    )
+    assert package_name not in sys.modules
+    assert f"{package_name}.action" not in sys.modules
+    assert str(old_python_path) not in sys.path
+    assert str(old_python_path) not in sys.path_importer_cache
+    assert sys.path[0] == str(new_python_path)
+    assert plan_function.get_python_function_cache_size() == 0
+
+    new_module = importlib.import_module(f"{package_name}.action")
+    assert new_module.VALUE == "new"
+    assert files(package_name).joinpath("skills", "SKILL.md").read_text() == 
"new"
+
+    # Each Pemja interpreter inserts its configured paths before this guard 
runs.
+    sys.path.insert(0, str(new_python_path))
+    assert not _python_dependency.ensure_python_dependency_generation(
+        "job-1", str(new_generation)
+    )
+    assert sys.path.count(str(new_python_path)) == 1
+    assert importlib.import_module(f"{package_name}.action") is new_module
+
+
+def test_generation_change_preserves_other_active_job(
+    tmp_path: Path, restore_import_state
+):
+    first_package = f"first_package_{uuid.uuid4().hex}"
+    second_package = f"second_package_{uuid.uuid4().hex}"
+    restore_import_state.update({first_package, second_package})
+
+    first_old_generation, first_old_python_path = _create_generation(
+        tmp_path, "first-old", first_package, "first-old"
+    )
+    second_generation, second_python_path = _create_generation(
+        tmp_path, "second", second_package, "second"
+    )
+    sys.path.insert(0, str(first_old_python_path))
+
+    _python_dependency.ensure_python_dependency_generation(
+        "job-1", str(first_old_generation)
+    )
+    first_old_module = importlib.import_module(f"{first_package}.action")
+
+    sys.path.insert(0, str(second_python_path))
+    _python_dependency.ensure_python_dependency_generation(
+        "job-2", str(second_generation)
+    )
+    second_module = importlib.import_module(f"{second_package}.action")
+
+    shutil.rmtree(first_old_generation)
+    first_new_generation, first_new_python_path = _create_generation(
+        tmp_path, "first-new", first_package, "first-new"
+    )
+    sys.path.insert(0, str(first_new_python_path))
+    _python_dependency.ensure_python_dependency_generation(
+        "job-1", str(first_new_generation)
+    )
+
+    assert first_old_module.VALUE == "first-old"
+    assert first_package not in sys.modules
+    assert importlib.import_module(f"{first_package}.action").VALUE == 
"first-new"
+    assert importlib.import_module(f"{second_package}.action") is second_module
+    assert str(second_python_path) in sys.path
+
+
+def test_failed_refresh_is_retried(tmp_path: Path, restore_import_state, 
monkeypatch):
+    package_name = f"retry_package_{uuid.uuid4().hex}"
+    restore_import_state.add(package_name)
+    old_generation, old_python_path = _create_generation(
+        tmp_path, "retry-old", package_name, "old"
+    )
+    sys.path.insert(0, str(old_python_path))
+    _python_dependency.ensure_python_dependency_generation(
+        "job-retry", str(old_generation)
+    )
+    importlib.import_module(f"{package_name}.action")
+
+    shutil.rmtree(old_generation)
+    new_generation, new_python_path = _create_generation(
+        tmp_path, "retry-new", package_name, "new"
+    )
+    sys.path.insert(0, str(new_python_path))
+    original_invalidate_caches = importlib.invalidate_caches
+
+    def fail_invalidate_caches() -> None:
+        msg = "injected cache invalidation failure"
+        raise RuntimeError(msg)
+
+    monkeypatch.setattr(
+        _python_dependency.importlib,
+        "invalidate_caches",
+        fail_invalidate_caches,
+    )
+    with pytest.raises(RuntimeError, match="injected cache invalidation 
failure"):
+        _python_dependency.ensure_python_dependency_generation(
+            "job-retry", str(new_generation)
+        )
+
+    assert _python_dependency._JOB_GENERATIONS["job-retry"] == 
str(old_generation)
+
+    monkeypatch.setattr(
+        _python_dependency.importlib,
+        "invalidate_caches",
+        original_invalidate_caches,
+    )
+    assert _python_dependency.ensure_python_dependency_generation(
+        "job-retry", str(new_generation)
+    )
+    assert importlib.import_module(f"{package_name}.action").VALUE == "new"
+
+
+def test_activate_uses_configured_python_path(tmp_path: Path, 
restore_import_state):
+    package_name = f"configured_path_{uuid.uuid4().hex}"
+    restore_import_state.add(package_name)
+    generation, python_path = _create_generation(
+        tmp_path, "configured", package_name, "configured"
+    )
+    normalized = _python_dependency._normalize_path(python_path)
+    assert normalized not in sys.path
+
+    assert _python_dependency.ensure_python_dependency_generation(
+        "job-configured", str(generation), str(python_path)
+    )
+    assert sys.path[0] == normalized
+    assert importlib.import_module(f"{package_name}.action").VALUE == 
"configured"
+
+    sys.path.remove(normalized)
+    assert not _python_dependency.ensure_python_dependency_generation(
+        "job-configured", str(generation), str(python_path)
+    )
+    assert sys.path[0] == normalized
+
+
+def test_generation_state_survives_helper_reload_from_job_requirements(
+    tmp_path: Path, restore_import_state
+):
+    package_name = f"helper_reload_{uuid.uuid4().hex}"
+    restore_import_state.add(package_name)
+
+    gen_a, path_a = _create_generation(tmp_path, "helper-a", package_name, "a")
+    gen_b, path_b = _create_generation(tmp_path, "helper-b", package_name, "b")
+    gen_c, path_c = _create_generation(tmp_path, "helper-c", package_name, "c")
+    helper_a = _copy_helper_into_generation(path_a)
+    helper_b = _copy_helper_into_generation(path_b)
+    helper_c = _copy_helper_into_generation(path_c)
+
+    loaded_a = _load_helper_from(helper_a)
+    assert loaded_a.ensure_python_dependency_generation(
+        "job-fa", str(gen_a), str(path_a)
+    )
+    assert importlib.import_module(f"{package_name}.action").VALUE == "a"
+
+    shutil.rmtree(gen_a)
+    assert loaded_a.ensure_python_dependency_generation(
+        "job-fa", str(gen_b), str(path_b)
+    )
+    assert sys.modules.get(_HELPER_MODULE) is not loaded_a
+
+    loaded_b = _load_helper_from(helper_b)
+    state = sys.modules[_python_dependency._STATE_MODULE_NAME]
+    assert loaded_b._JOB_GENERATIONS is state.job_generations
+    assert loaded_a._JOB_GENERATIONS is state.job_generations
+    assert loaded_b._JOB_GENERATIONS["job-fa"] == 
loaded_b._normalize_path(gen_b)
+    assert importlib.import_module(f"{package_name}.action").VALUE == "b"
+
+    shutil.rmtree(gen_b)
+    assert loaded_b.ensure_python_dependency_generation(
+        "job-fa", str(gen_c), str(path_c)
+    )
+    loaded_c = _load_helper_from(helper_c)
+    assert loaded_c._JOB_GENERATIONS["job-fa"] == 
loaded_c._normalize_path(gen_c)
+    assert importlib.import_module(f"{package_name}.action").VALUE == "c"
+
+
+def _create_generation(
+    tmp_path: Path, generation_name: str, package_name: str, value: str
+) -> tuple[Path, Path]:
+    generation = tmp_path / f"python-dist-{generation_name}"
+    python_path = generation / "python-files" / "user-code"
+    package_path = python_path / package_name
+    skills_path = package_path / "skills"
+    skills_path.mkdir(parents=True)
+    (package_path / "__init__.py").write_text("")
+    (package_path / "action.py").write_text(f"VALUE = {value!r}\n")
+    (skills_path / "SKILL.md").write_text(value)
+    return generation, python_path
+
+
+def _copy_helper_into_generation(python_path: Path) -> Path:
+    dest = python_path / "flink_agents" / "runtime" / "_python_dependency.py"
+    dest.parent.mkdir(parents=True, exist_ok=True)
+    (python_path / "flink_agents" / "__init__.py").write_text("")
+    (dest.parent / "__init__.py").write_text("")
+    shutil.copy(Path(_python_dependency.__file__), dest)
+    return dest
+
+
+def _load_helper_from(helper_file: Path) -> ModuleType:
+    spec = importlib.util.spec_from_file_location(_HELPER_MODULE, helper_file)
+    assert spec is not None
+    assert spec.loader is not None
+    module = importlib.util.module_from_spec(spec)
+    sys.modules[_HELPER_MODULE] = module
+    spec.loader.exec_module(module)
+    return module
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 95e215d7..ae3bedb3 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
@@ -96,10 +96,12 @@ class PythonBridgeManager implements AutoCloseable {
      * <p>Scans the agent plan for any {@link PythonFunction} action or {@link
      * PythonResourceProvider}. If neither is present, this method is a no-op 
and {@link
      * #isInitialized()} stays {@code false}. Otherwise it builds the {@link
-     * PythonEnvironmentManager}, opens an embedded {@link PythonInterpreter}, 
constructs the shared
-     * {@link PythonRunnerContextImpl}, wires the Java/Python resource 
adapters, and conditionally
+     * PythonEnvironmentManager}, opens an embedded {@link PythonInterpreter}, 
refreshes the shared
+     * import state for the current dependency generation, constructs the 
shared {@link
+     * PythonRunnerContextImpl}, wires the Java/Python resource adapters, and 
conditionally
      * initializes the Python action executor and the Python resource adapter 
(each only when the
-     * corresponding component is present in the plan).
+     * corresponding component is present in the plan). The generation guard 
runs immediately after
+     * 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.
@@ -154,6 +156,20 @@ class PythonBridgeManager implements AutoCloseable {
             pythonEnvironmentManager.open();
             EmbeddedPythonEnvironment env = 
pythonEnvironmentManager.createEnvironment();
             pythonInterpreter = env.getInterpreter();
+            String dependencyGeneration = 
pythonEnvironmentManager.getBaseDirectory();
+            String pythonPath = env.getEnv().get("PYTHONPATH");
+            boolean dependencyGenerationChanged =
+                    
PythonDependencyGenerationManager.ensurePythonDependencyGeneration(
+                            pythonInterpreter,
+                            jobId,
+                            dependencyGeneration,
+                            pythonPath == null ? "" : pythonPath);
+            if (dependencyGenerationChanged) {
+                LOG.info(
+                        "Activated Python dependency generation {} for job 
{}.",
+                        dependencyGeneration,
+                        jobId);
+            }
             pythonRunnerContext =
                     new PythonRunnerContextImpl(
                             metricGroup,
diff --git 
a/runtime/src/main/java/org/apache/flink/agents/runtime/operator/PythonDependencyGenerationManager.java
 
b/runtime/src/main/java/org/apache/flink/agents/runtime/operator/PythonDependencyGenerationManager.java
new file mode 100644
index 00000000..9c5ad934
--- /dev/null
+++ 
b/runtime/src/main/java/org/apache/flink/agents/runtime/operator/PythonDependencyGenerationManager.java
@@ -0,0 +1,73 @@
+/*
+ * 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.operator;
+
+import org.apache.flink.api.common.JobID;
+import pemja.core.PythonInterpreter;
+
+import static org.apache.flink.util.Preconditions.checkNotNull;
+import static org.apache.flink.util.Preconditions.checkState;
+
+/** Coordinates Flink Python dependency generations with Pemja's shared import 
state. */
+final class PythonDependencyGenerationManager {
+
+    private static final String PYTHON_IMPORT =
+            "from flink_agents.runtime import _python_dependency";
+    private static final String ENSURE_PYTHON_DEPENDENCY_GENERATION =
+            "_python_dependency.ensure_python_dependency_generation";
+
+    private PythonDependencyGenerationManager() {}
+
+    /**
+     * Refreshes Pemja's process-level import state for this job's current 
dependency directory.
+     *
+     * <p>Must be called after {@link
+     * 
org.apache.flink.agents.runtime.env.EmbeddedPythonEnvironment#getInterpreter()} 
has
+     * constructed the interpreter and before any user action or resource is 
imported.
+     *
+     * <p>This guard only tracks same-job failover, where Flink rematerializes 
the same artifacts
+     * under a new {@code python-dist-*} directory. Cross-job reuse of a 
TaskManager is out of
+     * scope.
+     *
+     * @param interpreter the Pemja interpreter that shares process-level 
import state
+     * @param jobId the Flink job whose dependency generation should be 
activated
+     * @param generation the current Flink-managed dependency directory
+     * @param pythonPath the environment {@code PYTHONPATH} for this 
generation; used to activate
+     *     generation entries even if they are not yet present on {@code 
sys.path}
+     */
+    static boolean ensurePythonDependencyGeneration(
+            PythonInterpreter interpreter, JobID jobId, String generation, 
String pythonPath) {
+        checkNotNull(interpreter);
+        checkNotNull(jobId);
+        checkNotNull(generation);
+        checkNotNull(pythonPath);
+
+        interpreter.exec(PYTHON_IMPORT);
+        Object result =
+                interpreter.invoke(
+                        ENSURE_PYTHON_DEPENDENCY_GENERATION,
+                        jobId.toHexString(),
+                        generation,
+                        pythonPath);
+        checkState(
+                result instanceof Boolean,
+                "Python dependency generation guard returned an invalid 
result: %s",
+                result);
+        return (Boolean) result;
+    }
+}
diff --git 
a/runtime/src/test/java/org/apache/flink/agents/runtime/operator/PythonDependencyGenerationManagerTest.java
 
b/runtime/src/test/java/org/apache/flink/agents/runtime/operator/PythonDependencyGenerationManagerTest.java
new file mode 100644
index 00000000..7a954ee3
--- /dev/null
+++ 
b/runtime/src/test/java/org/apache/flink/agents/runtime/operator/PythonDependencyGenerationManagerTest.java
@@ -0,0 +1,84 @@
+/*
+ * 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.operator;
+
+import org.apache.flink.api.common.JobID;
+import org.junit.jupiter.api.Test;
+import org.mockito.InOrder;
+import pemja.core.PythonInterpreter;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/** Tests for {@link PythonDependencyGenerationManager}. */
+class PythonDependencyGenerationManagerTest {
+
+    @Test
+    void importsGuardModuleBeforeInvokingGenerationCheck() {
+        PythonInterpreter interpreter = mock(PythonInterpreter.class);
+        JobID jobId = new JobID();
+        String generation = "/tmp/python-dist-current";
+        String pythonPath = "/tmp/python-dist-current/python-files";
+
+        when(interpreter.invoke(
+                        
"_python_dependency.ensure_python_dependency_generation",
+                        jobId.toHexString(),
+                        generation,
+                        pythonPath))
+                .thenReturn(true);
+
+        assertThat(
+                        
PythonDependencyGenerationManager.ensurePythonDependencyGeneration(
+                                interpreter, jobId, generation, pythonPath))
+                .isTrue();
+
+        InOrder calls = inOrder(interpreter);
+        calls.verify(interpreter).exec("from flink_agents.runtime import 
_python_dependency");
+        calls.verify(interpreter)
+                .invoke(
+                        
"_python_dependency.ensure_python_dependency_generation",
+                        jobId.toHexString(),
+                        generation,
+                        pythonPath);
+    }
+
+    @Test
+    void rejectsNonBooleanGenerationResult() {
+        PythonInterpreter interpreter = mock(PythonInterpreter.class);
+        JobID jobId = new JobID();
+        String generation = "/tmp/python-dist-current";
+        String pythonPath = "/tmp/python-dist-current/python-files";
+
+        when(interpreter.invoke(
+                        
"_python_dependency.ensure_python_dependency_generation",
+                        jobId.toHexString(),
+                        generation,
+                        pythonPath))
+                .thenReturn("yes");
+
+        assertThatThrownBy(
+                        () ->
+                                
PythonDependencyGenerationManager.ensurePythonDependencyGeneration(
+                                        interpreter, jobId, generation, 
pythonPath))
+                .isInstanceOf(IllegalStateException.class)
+                .hasMessageContaining("invalid result");
+    }
+}

Reply via email to