This is an automated email from the ASF dual-hosted git repository.
uranusjr pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new 77c29134b37 Load Java Dags from a bundle path that holds a colon
(#76432)
77c29134b37 is described below
commit 77c29134b3786828c4450553de0e4870c69a02f7
Author: Jason(Zhe-You) Liu <[email protected]>
AuthorDate: Wed Nov 4 23:18:56 2026 +0800
Load Java Dags from a bundle path that holds a colon (#76432)
Co-authored-by: Claude <[email protected]>
Co-authored-by: Tzu-ping Chung <[email protected]>
---
.../language-sdks/java.rst | 21 ++
.../airflow/sdk/coordinators/java/coordinator.py | 95 +++++++-
.../task_sdk/coordinators/java/test_coordinator.py | 240 +++++++++++++++++++++
3 files changed, 354 insertions(+), 2 deletions(-)
diff --git a/airflow-core/docs/authoring-and-scheduling/language-sdks/java.rst
b/airflow-core/docs/authoring-and-scheduling/language-sdks/java.rst
index 2e8aba27521..621864a40f0 100644
--- a/airflow-core/docs/authoring-and-scheduling/language-sdks/java.rst
+++ b/airflow-core/docs/authoring-and-scheduling/language-sdks/java.rst
@@ -1092,6 +1092,27 @@ Register every Dag bundle a coordinator names in
``[dag_processor] dag_bundle_co
The ``[sdk]`` configuration is read at startup, so restart these components
after changing it. A rebuilt JAR is
picked up on the next task, since every task instance starts a new JVM.
+.. _java-sdk/bundle-path-separator:
+
+Dag bundle paths that contain a colon
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+The ``java`` command cannot load a classpath containing paths that use the
``:`` (colon) character (on POSIX
+systems). The version directory of a Dag bundle may contain one, for example
when it is named after a
+timestamp. The coordinator tries to handle this automatically by reading such
a bundle through a symbolic link
+created in a temporary directory. A file or directory *inside* the Dag bundle
still cannot contain the colon
+separator.
+
+This approach should generally work. On the off chance it does not, however,
all such links are located in
+``airflow-java-classpath-<uid>`` under the temporary directory (``$TMPDIR`` on
Linux, usually ``/tmp``).
+Airflow logs should also report which link corresponds to which bundle.
+
+Each link is named after the first 16 hexadecimal digits of the SHA-256 hash
of the bundle version's absolute
+path, so the same path always gets the same link. Links are not removed
immediately when they are not needed,
+so some dead ones are expected. They are harmless. Airflow does delayed
cleanups, so they should not grow
+unbounded. You can also safely delete them *when Airflow is not running*;
Airflow will recreate them
+automatically the next time they are needed.
+
.. _java-sdk/coordinator-config:
:class:`~airflow.sdk.coordinators.java.JavaCoordinator` configuration
diff --git a/task-sdk/src/airflow/sdk/coordinators/java/coordinator.py
b/task-sdk/src/airflow/sdk/coordinators/java/coordinator.py
index d2888f6891c..18b6b75923b 100644
--- a/task-sdk/src/airflow/sdk/coordinators/java/coordinator.py
+++ b/task-sdk/src/airflow/sdk/coordinators/java/coordinator.py
@@ -19,9 +19,13 @@
from __future__ import annotations
+import contextlib
+import hashlib
import os
import pathlib
+import secrets
import stat
+import tempfile
import zipfile
import zlib
from typing import TYPE_CHECKING, Final
@@ -92,13 +96,95 @@ def _iter_dir(directory: pathlib.Path) ->
Iterator[pathlib.Path]:
yield from children
+def _get_classpath_link_dir() -> pathlib.Path:
+ """
+ Return this user's directory of classpath links, creating it if needed.
+
+ :raises ValueError: when the directory's path holds ``os.pathsep``.
+ :raises PermissionError: when the directory is not a real directory owned
by this user, or
+ others can write to it. Anyone who can write there could point a link
at their own JARs.
+ """
+ uid = os.getuid()
+ path = pathlib.Path(tempfile.gettempdir(), f"airflow-java-classpath-{uid}")
+ if os.pathsep in os.fspath(path):
+ raise ValueError(
+ f"Cannot keep Java classpath links in {path}, because its path
holds {os.pathsep!r}. "
+ "Point TMPDIR to a directory whose path does not."
+ )
+ with contextlib.suppress(FileExistsError):
+ path.mkdir(mode=0o700)
+ st = path.lstat()
+ if not stat.S_ISDIR(st.st_mode) or st.st_uid != uid or st.st_mode & 0o022:
+ raise PermissionError(
+ f"Cannot keep Java classpath links in {path}: it must be a
directory owned by "
+ "the current user that no other user can write to."
+ )
+ return path
+
+
+def _remove_dangling_links(link_dir: pathlib.Path) -> None:
+ for entry in link_dir.iterdir():
+ if entry.is_symlink() and not entry.exists():
+ with contextlib.suppress(FileNotFoundError):
+ entry.unlink()
+
+
+def _get_classpath_link(root: pathlib.Path) -> pathlib.Path:
+ """
+ Return a symlink to *root* whose own path holds no ``os.pathsep``.
+
+ The JVM splits a classpath on ``os.pathsep`` and has no way to escape it.
The link name is a
+ hash of *root*, so every process of this user shares one link per root. It
is replaced
+ atomically, and a link whose target is gone is removed when a new one is
created.
+ """
+ target = os.fspath(root.absolute())
+ link_dir = _get_classpath_link_dir()
+ link = link_dir / hashlib.sha256(os.fsencode(target)).hexdigest()[:16]
+ with contextlib.suppress(OSError):
+ if os.readlink(link) == target:
+ return link
+ _remove_dangling_links(link_dir)
+ staging = link_dir /
f"{link.name}.{os.getpid()}.{secrets.token_hex(4)}.tmp"
+ os.symlink(target, staging, target_is_directory=True)
+ try:
+ os.replace(staging, link)
+ except BaseException:
+ staging.unlink(missing_ok=True)
+ raise
+ log.debug("Linked a bundle path that holds the classpath separator",
bundle_path=target, link=link)
+ return link
+
+
+def _find_root(path: pathlib.Path, roots: Sequence[pathlib.Path]) ->
pathlib.Path | None:
+ return next((root for root in roots if path.is_relative_to(root)), None)
+
+
def _calculate_classpath(roots: Sequence[pathlib.Path], *, primary:
pathlib.Path | None = None) -> str:
- """Return the classpath of every JAR under *roots*. *primary*, when given,
goes first."""
+ """
+ Return the classpath of every JAR under *roots*. *primary*, when given,
goes first.
+
+ A root whose path holds ``os.pathsep``, such as a bundle version directory
named after a
+ timestamp, is reached through a symlink from :func:`_get_classpath_link`,
which this creates
+ if needed.
+
+ :raises ValueError: when a JAR's path below its root still holds
``os.pathsep``, or when the
+ temporary directory's path holds it.
+ """
jars = sorted(_find_jars(roots), key=lambda p: p.as_posix())
if primary is not None:
target = primary.resolve()
jars = [primary, *(p for p in jars if p.resolve() != target)]
- return os.pathsep.join(p.as_posix() for p in jars)
+ placed = [(p, _find_root(p, roots)) for p in jars]
+ if bad := [p for p, r in placed if os.pathsep in os.fspath(p if r is None
else p.relative_to(r))]:
+ raise ValueError(
+ f"Cannot put these JARs on a Java classpath, because
{os.pathsep!r} separates its "
+ f"entries: {', '.join(map(os.fspath, bad))}. Rename or move them."
+ )
+ linked_roots = {r for _, r in placed if r is not None and os.pathsep in
os.fspath(r)}
+ links = {r: _get_classpath_link(r) for r in linked_roots}
+ for root, link in links.items():
+ log.info("Bundle reached by classpath through a link",
bundle_path=root, link=link)
+ return os.pathsep.join((links[r] / p.relative_to(r) if r in links else
p).as_posix() for p, r in placed)
@attrs.define
@@ -262,6 +348,11 @@ class JavaCoordinator(SubprocessCoordinator):
picks the one that parses a bundle. A JAR whose Main-Class another JAR in
the
bundle also sets is rejected.
+ The JVM cannot load a JAR whose path holds the classpath separator (``:``
on
+ POSIX). The coordinator reaches a Dag bundle whose own path holds it
through
+ a symlink in the temporary directory, but a file or directory name inside
+ the bundle must not hold it.
+
The default *task_startup_timeout* should plenty long enough since a task-
containing JAR is not supposed to consume significant time to perform setup
(it should happen in individual tasks instead). However, if the launch time
diff --git a/task-sdk/tests/task_sdk/coordinators/java/test_coordinator.py
b/task-sdk/tests/task_sdk/coordinators/java/test_coordinator.py
index 15ebfb75426..f79406aa45b 100644
--- a/task-sdk/tests/task_sdk/coordinators/java/test_coordinator.py
+++ b/task-sdk/tests/task_sdk/coordinators/java/test_coordinator.py
@@ -18,11 +18,14 @@
from __future__ import annotations
+import concurrent.futures
import json
import os
import pathlib
import re
+import shutil
import socket
+import stat
import subprocess
import zlib
from unittest.mock import MagicMock, patch
@@ -36,6 +39,7 @@ from airflow.sdk.coordinators._subprocess import
_PopenActivitySubprocess
from airflow.sdk.coordinators.java.coordinator import (
JavaCoordinator,
_calculate_classpath,
+ _get_classpath_link,
_JarInfo,
_JarMetadata,
_walk_jars,
@@ -167,6 +171,201 @@ class TestCalculateClasspath:
assert result.split(os.pathsep) == [b.as_posix(), a.as_posix()]
+TIMESTAMPED_VERSION = f"2026-11-02T15{os.pathsep}52{os.pathsep}20Z"
+
+
[email protected]
+def link_dir(tmp_path):
+ """Point the coordinator's temporary directory into *tmp_path* and return
where its links go."""
+ tmp = tmp_path / "tmp"
+ tmp.mkdir()
+ with patch(
+ "airflow.sdk.coordinators.java.coordinator.tempfile.gettempdir",
+ autospec=True,
+ return_value=os.fspath(tmp),
+ ):
+ yield tmp / f"airflow-java-classpath-{os.getuid()}"
+
+
+def _make_timestamped_bundle(parent: pathlib.Path, name: str =
TIMESTAMPED_VERSION) -> pathlib.Path:
+ root = parent / name
+ (root / "libs").mkdir(parents=True)
+ _make_jar(root / "app.jar", main_class="com.example.Dags",
schema_version="2026-10-30")
+ _make_jar(root / "libs" / "dep.jar", main_class=None)
+ return root
+
+
+class TestClasspathLink:
+ def test_root_without_separator_is_not_linked(self, tmp_path, link_dir):
+ jar = _make_jar(tmp_path / "app.jar")
+
+ assert _calculate_classpath([tmp_path]) == jar.as_posix()
+ assert not link_dir.exists()
+
+ def test_root_with_separator_is_reached_through_a_link(self, tmp_path,
link_dir):
+ root = _make_timestamped_bundle(tmp_path)
+
+ result = _calculate_classpath([root], primary=root / "libs" /
"dep.jar")
+
+ [link] = link_dir.iterdir()
+ assert result.split(os.pathsep) == [f"{link.as_posix()}/libs/dep.jar",
f"{link.as_posix()}/app.jar"]
+ assert os.readlink(link) == os.fspath(root)
+ assert stat.S_IMODE(link_dir.stat().st_mode) == 0o700
+
+ def test_mixed_roots_link_only_the_root_with_separator(self, tmp_path,
link_dir):
+ plain = tmp_path / "plain"
+ plain.mkdir()
+ lib = _make_jar(plain / "lib.jar", main_class=None)
+ root = _make_timestamped_bundle(tmp_path)
+
+ result = _calculate_classpath([plain, root])
+
+ [link] = link_dir.iterdir()
+ assert os.readlink(link) == os.fspath(root)
+ assert result.split(os.pathsep) == [
+ f"{link.as_posix()}/app.jar",
+ f"{link.as_posix()}/libs/dep.jar",
+ lib.as_posix(),
+ ]
+
+ @pytest.mark.parametrize(
+ ("inner_first", "link_count"),
+ [pytest.param(False, 1, id="outer-first"), pytest.param(True, 2,
id="inner-first")],
+ )
+ def test_nested_roots(self, tmp_path, link_dir, inner_first, link_count):
+ root = _make_timestamped_bundle(tmp_path)
+ roots = [root / "libs", root] if inner_first else [root, root / "libs"]
+
+ entries = _calculate_classpath(roots).split(os.pathsep)
+
+ assert sorted(pathlib.Path(e).resolve() for e in entries) == sorted(
+ [(root / "app.jar").resolve(), (root / "libs" /
"dep.jar").resolve()]
+ )
+ assert len(list(link_dir.iterdir())) == link_count
+
+ def test_link_is_reused(self, tmp_path, link_dir):
+ root = _make_timestamped_bundle(tmp_path)
+ link = _get_classpath_link(root)
+ inode = link.lstat().st_ino
+
+ assert _get_classpath_link(root) == link
+ assert link.lstat().st_ino == inode
+
+ @patch(
+ "airflow.sdk.coordinators.java.coordinator.os.replace",
+ autospec=True,
+ side_effect=OSError("replace failed"),
+ )
+ def test_staging_link_is_removed_when_replacing_fails(self, mock_replace,
tmp_path, link_dir):
+ root = _make_timestamped_bundle(tmp_path)
+
+ with pytest.raises(OSError, match="replace failed"):
+ _get_classpath_link(root)
+
+ assert list(link_dir.iterdir()) == []
+
+ def test_link_to_another_target_is_replaced(self, tmp_path, link_dir):
+ root = _make_timestamped_bundle(tmp_path)
+ link = _get_classpath_link(root)
+ link.unlink()
+ link.symlink_to(tmp_path)
+
+ assert _get_classpath_link(root) == link
+ assert os.readlink(link) == os.fspath(root)
+
+ def test_dangling_links_are_removed_when_a_link_is_created(self, tmp_path,
link_dir):
+ old = _get_classpath_link(_make_timestamped_bundle(tmp_path / "old"))
+ kept = _get_classpath_link(_make_timestamped_bundle(tmp_path / "kept"))
+ shutil.rmtree(tmp_path / "old")
+
+ new = _get_classpath_link(_make_timestamped_bundle(tmp_path / "new"))
+
+ assert sorted(link_dir.iterdir()) == sorted([kept, new])
+ assert not old.is_symlink()
+
+ def test_concurrent_callers_share_one_link(self, tmp_path, link_dir):
+ root = _make_timestamped_bundle(tmp_path)
+
+ with concurrent.futures.ThreadPoolExecutor(max_workers=16) as pool:
+ links = set(pool.map(lambda _: _get_classpath_link(root),
range(64)))
+
+ assert len(links) == 1
+ assert list(link_dir.iterdir()) == [*links]
+
+ @pytest.mark.parametrize("unsafe", ["writable-by-others", "symlink",
"file", "other-owner"])
+ def test_rejects_an_unsafe_link_dir(self, tmp_path, link_dir, unsafe):
+ root = _make_timestamped_bundle(tmp_path)
+ uid = os.getuid()
+ if unsafe == "writable-by-others":
+ link_dir.mkdir(mode=0o700)
+ link_dir.chmod(0o777)
+ elif unsafe == "symlink":
+ (tmp_path / "elsewhere").mkdir(mode=0o700)
+ link_dir.symlink_to(tmp_path / "elsewhere")
+ elif unsafe == "file":
+ link_dir.write_bytes(b"")
+ else:
+ uid += 1
+
+ with (
+ patch("airflow.sdk.coordinators.java.coordinator.os.getuid",
autospec=True, return_value=uid),
+ pytest.raises(PermissionError, match="Cannot keep Java classpath
links"),
+ ):
+ _get_classpath_link(root)
+
+ def test_rejects_a_link_dir_holding_the_separator(self, tmp_path):
+ root = _make_timestamped_bundle(tmp_path)
+ tmp = tmp_path / f"tmp{os.pathsep}dir"
+ tmp.mkdir()
+
+ with (
+ patch(
+
"airflow.sdk.coordinators.java.coordinator.tempfile.gettempdir",
+ autospec=True,
+ return_value=os.fspath(tmp),
+ ),
+ pytest.raises(ValueError, match="Cannot keep Java classpath
links"),
+ ):
+ _calculate_classpath([root])
+
+ assert list(tmp.iterdir()) == []
+
+ def test_separator_inside_the_bundle_raises_without_linking(self,
tmp_path, link_dir):
+ root = _make_timestamped_bundle(tmp_path)
+ nested = root / f"a{os.pathsep}b"
+ nested.mkdir()
+ jar = _make_jar(nested / "app.jar")
+
+ with pytest.raises(ValueError, match="Cannot put these JARs on a Java
classpath") as ctx:
+ _calculate_classpath([root])
+
+ assert os.fspath(jar) in str(ctx.value)
+ assert not link_dir.exists()
+
+ @pytest.mark.skipif(shutil.which("java") is None or shutil.which("javac")
is None, reason="needs a JDK")
+ def test_jvm_loads_jars_through_the_link(self, tmp_path, link_dir):
+ src = tmp_path / "src"
+ src.mkdir()
+ (src / "Dep.java").write_text('public class Dep { public static String
hi() { return "loaded"; } }')
+ (src / "Main.java").write_text(
+ "public class Main { public static void main(String[] a) {
System.out.println(Dep.hi()); } }"
+ )
+ subprocess.run(["javac", "-d", os.fspath(src), *map(os.fspath,
src.glob("*.java"))], check=True)
+ root = tmp_path / TIMESTAMPED_VERSION
+ (root / "libs").mkdir(parents=True)
+ make_jar(root / "app.jar", entries={"Main.class": (src /
"Main.class").read_bytes()})
+ make_jar(root / "libs" / "dep.jar", entries={"Dep.class": (src /
"Dep.class").read_bytes()})
+
+ result = subprocess.run(
+ ["java", "-classpath", _calculate_classpath([root]), "Main"],
+ capture_output=True,
+ text=True,
+ check=True,
+ )
+
+ assert result.stdout.strip() == "loaded"
+
+
class TestMainJar:
def test_returns_main_class_from_jar(self, tmp_path):
_make_jar(tmp_path.joinpath("app.jar"), main_class="com.example.Main",
schema_version="2026-06-16")
@@ -408,6 +607,23 @@ class TestJavaCoordinatorExecuteTask:
classpath = cmd[cp_idx + 1]
assert jars_dir.joinpath("app.jar").as_posix() in classpath
+ def test_timestamped_bundle_path_is_linked(self, tmp_path, link_dir,
mock_client):
+ root = _make_timestamped_bundle(tmp_path)
+ bundle = {
+ "name": "java-task-handlers",
+ "classpath": "airflow.dag_processing.bundles.local.LocalDagBundle",
+ "kwargs": {"path": str(root)},
+ }
+ with conf_vars({("dag_processor", "dag_bundle_config_list"):
json.dumps([bundle])}):
+ cmd = self._captured_popen_cmd(bundle["name"], mock_client)
+
+ [link] = link_dir.iterdir()
+ classpath = cmd[cmd.index("-classpath") + 1]
+ assert classpath.split(os.pathsep) == [
+ f"{link.as_posix()}/app.jar",
+ f"{link.as_posix()}/libs/dep.jar",
+ ]
+
def test_main_class_present(self, java_task_handlers, mock_client):
cmd = self._captured_popen_cmd(java_task_handlers, mock_client)
assert "com.example.TaskRunner" in cmd
@@ -707,3 +923,27 @@ class TestBuildParseDagCommand:
"--comm=127.0.0.1:1001",
"--logs=127.0.0.1:1002",
]
+
+
@patch("airflow.sdk.coordinators._subprocess._set_close_on_exec_above_stderr",
autospec=True)
+ @patch("airflow.sdk.coordinators._subprocess.signal.signal", autospec=True)
+ @patch("airflow.sdk.coordinators._subprocess.os.execvpe", autospec=True,
side_effect=OSError("exec"))
+ def test_parse_dag_links_a_timestamped_bundle_path(
+ self, mock_execvpe, mock_signal, mock_close_on_exec, tmp_path, link_dir
+ ):
+ root = _make_timestamped_bundle(tmp_path)
+
+ with pytest.raises(OSError, match="exec"):
+ JavaCoordinator().parse_dag(
+ path=root / "app.jar",
+ bundle_path=root,
+ comm_address=("127.0.0.1", 1001),
+ logs_address=("127.0.0.1", 1002),
+ report_schema_version=lambda _: None,
+ )
+
+ [link] = link_dir.iterdir()
+ classpath = mock_execvpe.call_args.args[1][2]
+ assert classpath.split(os.pathsep) == [
+ f"{link.as_posix()}/app.jar",
+ f"{link.as_posix()}/libs/dep.jar",
+ ]