diff --git a/.dockerignore b/.dockerignore
index df08c066ce3b4..368d437dcfd1a 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -39,6 +39,7 @@
!task-sdk/
!airflow-ctl/
!go-sdk/
+!sdk/
# Add all "test" distributions
!tests
diff --git a/.github/boring-cyborg.yml b/.github/boring-cyborg.yml
index 21b44a9fb840f..3f5477f4512c5 100644
--- a/.github/boring-cyborg.yml
+++ b/.github/boring-cyborg.yml
@@ -189,6 +189,9 @@ labelPRBasedOnFilePath:
provider:keycloak:
- providers/keycloak/**
+ provider:sdk-java:
+ - providers/sdk/java/**
+
provider:microsoft-azure:
- providers/microsoft/azure/**
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 6c0a97b801c9a..066cb3ab6ab61 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -492,6 +492,12 @@ repos:
language: python
pass_filenames: false
files: ^dev/registry/registry_tools/types\.py$|^registry/src/_data/types\.json$
+ - id: check-task-instance-dto-sync
+ name: Check BaseTaskInstanceDTO duplicate is in sync between core and task-sdk
+ entry: ./scripts/ci/prek/check_task_instance_dto_sync.py
+ language: python
+ pass_filenames: false
+ files: ^airflow-core/src/airflow/executors/workloads/task\.py$|^task-sdk/src/airflow/sdk/execution_time/workloads/task\.py$
- id: ruff
name: Run 'ruff' for extremely fast Python linting
description: "Run 'ruff' for extremely fast Python linting"
@@ -1111,6 +1117,22 @@ repos:
files: ^airflow-core/src/airflow/migrations/versions/.*\.py$
additional_dependencies: [rich>=13.6.0]
require_serial: true
+ - id: check-java-serialization-compatibility
+ name: Check Java SDK serialization compatibility
+ description: >-
+ Verify that the Java and Python SDKs produce identical DAG serialization
+ for all test cases defined in java-sdk/validation/serialization/test_dags.yaml
+ entry: ./scripts/ci/prek/check_java_serialization_compatibility.py
+ language: python
+ stages: ['manual']
+ pass_filenames: false
+ require_serial: true
+ files: >
+ (?x)
+ ^java-sdk/sdk/src/.*\.(kt|java)$|
+ ^java-sdk/validation/serialization/.*$|
+ ^airflow-core/src/airflow/serialization/.*\.py$|
+ ^task-sdk/src/airflow/sdk/definitions/.*\.py$
## ADD MOST PREK HOOK ABOVE THAT LINE
# The below prek hooks are those requiring CI image to be built
## ONLY ADD PREK HOOKS HERE THAT REQUIRE CI IMAGE
diff --git a/Dockerfile b/Dockerfile
index 60c33e922df1a..d3ace9cdcb6bb 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1048,8 +1048,8 @@ function install_airflow_and_providers_from_docker_context_files(){
install_airflow_core_distribution=("apache-airflow-core==${AIRFLOW_VERSION}")
fi
- # Find Provider/TaskSDK/CTL distributions in docker-context files
- readarray -t airflow_distributions< <(python /scripts/docker/get_distribution_specs.py /docker-context-files/apache?airflow?{providers,task?sdk,airflowctl}*.{whl,tar.gz} 2>/dev/null || true)
+ # Find Provider/TaskSDK/CTL/Coordinator distributions in docker-context files
+ readarray -t airflow_distributions< <(python /scripts/docker/get_distribution_specs.py /docker-context-files/apache?airflow?{providers,task?sdk,airflowctl,coordinators?java}*.{whl,tar.gz} 2>/dev/null || true)
echo
echo "${COLOR_BLUE}Found provider distributions in docker-context-files folder: ${airflow_distributions[*]}${COLOR_RESET}"
echo
diff --git a/Dockerfile.ci b/Dockerfile.ci
index 3f8df8a6efab7..0968ae9e0e91b 100644
--- a/Dockerfile.ci
+++ b/Dockerfile.ci
@@ -1222,6 +1222,30 @@ function environment_initialization() {
export AIRFLOW__SCHEDULER__GO_WORKER=True
fi
+ if [[ ${JAVA_SDK=} == "true" ]]; then
+ echo
+ echo "${COLOR_BLUE}Setting up Java SDK${COLOR_RESET}"
+ echo
+
+ # Install Java and the Java SDK provider
+ bash /opt/airflow/scripts/in_container/java_sdk_setup.sh
+
+ # Set JAVA_HOME and PATH before Gradle build so gradlew can find java
+ export JAVA_HOME=/files/openjdk
+ export PATH=/files/openjdk/bin:${PATH}
+
+ # Build both Java SDK bundles (stub + pure Java)
+ export JAVA_SDK_SRC_DIR=/opt/airflow/java-sdk
+ export BUNDLES_OUTPUT_DIR=/files/java-sdk-bundles
+ bash /opt/airflow/scripts/in_container/java_sdk_build.sh
+
+ # Source the generated environment configuration
+ # shellcheck disable=SC1091
+ source /files/java-sdk-bundles/java_sdk_env.sh
+
+ echo "${COLOR_BLUE}Java SDK setup complete.${COLOR_RESET}"
+ fi
+
RUN_TESTS=${RUN_TESTS:="false"}
CI=${CI:="false"}
diff --git a/airflow-core/docs/extra-packages-ref.rst b/airflow-core/docs/extra-packages-ref.rst
index 2646b0a7c3079..9fb579c9b08ec 100644
--- a/airflow-core/docs/extra-packages-ref.rst
+++ b/airflow-core/docs/extra-packages-ref.rst
@@ -178,6 +178,17 @@ all the ``airflow`` packages together - similarly to what happened in Airflow 2.
``airflow-task-sdk`` separately, if you want to install providers, you need to install them separately as
``apache-airflow-providers-*`` distribution packages.
+Multi-Language extras
+=====================
+
+These are extras that add dependencies needed for integration with other languages runtimes. Currently we have only Java SDK related extra, but in the future we might add more extras related to other languages runtimes.
+
++----------+------------------------------------------+------------------------------------------------------------------+
+| extra | install command | enables |
++==========+==========================================+==================================================================+
+| sdk.java | ``pip install apache-airflow[sdk.java]`` | JavaCoordinator for both dag processing and workload execution. |
++----------+------------------------------------------+------------------------------------------------------------------+
+
Apache Software extras
======================
diff --git a/airflow-core/pyproject.toml b/airflow-core/pyproject.toml
index a875d9e9c77e7..0d4d45d7e40db 100644
--- a/airflow-core/pyproject.toml
+++ b/airflow-core/pyproject.toml
@@ -249,6 +249,7 @@ exclude = [
"../shared/serialization/src/airflow_shared/serialization" = "src/airflow/_shared/serialization"
"../shared/state/src/airflow_shared/state" = "src/airflow/_shared/state"
"../shared/timezones/src/airflow_shared/timezones" = "src/airflow/_shared/timezones"
+"../shared/workloads/src/airflow_shared/workloads" = "src/airflow/_shared/workloads"
"../shared/listeners/src/airflow_shared/listeners" = "src/airflow/_shared/listeners"
"../shared/plugins_manager/src/airflow_shared/plugins_manager" = "src/airflow/_shared/plugins_manager"
"../shared/providers_discovery/src/airflow_shared/providers_discovery" = "src/airflow/_shared/providers_discovery"
@@ -337,6 +338,7 @@ shared_distributions = [
"apache-airflow-shared-serialization",
"apache-airflow-shared-state",
"apache-airflow-shared-timezones",
+ "apache-airflow-shared-workloads",
"apache-airflow-shared-plugins-manager",
"apache-airflow-shared-providers-discovery",
]
diff --git a/airflow-core/src/airflow/_shared/workloads b/airflow-core/src/airflow/_shared/workloads
new file mode 120000
index 0000000000000..f25d9e16ea1d9
--- /dev/null
+++ b/airflow-core/src/airflow/_shared/workloads
@@ -0,0 +1 @@
+../../../../shared/workloads/src/airflow_shared/workloads
\ No newline at end of file
diff --git a/airflow-core/src/airflow/config_templates/config.yml b/airflow-core/src/airflow/config_templates/config.yml
index 8a060007978ee..a5a4ff9aa2157 100644
--- a/airflow-core/src/airflow/config_templates/config.yml
+++ b/airflow-core/src/airflow/config_templates/config.yml
@@ -1967,6 +1967,43 @@ workers:
type: integer
example: ~
default: "60"
+sdk:
+ description: Settings for non-Python SDK runtime coordination
+ options:
+ coordinators:
+ description: |
+ JSON list of runtime coordinator entries.
+
+ Each entry is an object with ``name``, ``classpath`` and optional
+ ``kwargs``. ``classpath`` is resolved via ``import_string`` and
+ constructed with ``kwargs`` once per process. Entries are
+ independent instances, so the same ``classpath`` can be configured
+ multiple times with different ``kwargs`` (for example, two
+ ``JavaCoordinator`` instances pinned to different JDK versions).
+ version_added: 3.1.7
+ type: string
+ example: |
+ [
+ {
+ "name": "jdk-17",
+ "classpath": "airflow.sdk.coordinators.java.JavaCoordinator",
+ "kwargs": {"java_executable": "/usr/lib/jvm/java-17-openjdk/bin/java", "jvm_args": ["-Xmx1024m"]}
+ }
+ ]
+ default: ~
+ queue_to_coordinator:
+ description: |
+ JSON mapping of queue names to coordinator ``name`` from
+ ``[sdk] coordinators``.
+
+ When a task's ``language`` field is not set, this mapping is checked
+ to route the task to a configured coordinator instance based on its
+ queue. This is useful when queues are used as environment or
+ isolation identifiers (e.g. ``legacy-java``, ``modern-java``).
+ version_added: 3.1.7
+ type: string
+ example: '{"legacy-java": "jdk-11", "modern-java": "jdk-17"}'
+ default: ~
api_auth:
description: Settings relating to authentication on the Airflow APIs
options:
diff --git a/airflow-core/src/airflow/dag_processing/manager.py b/airflow-core/src/airflow/dag_processing/manager.py
index 8d497ca7508e3..1221487818387 100644
--- a/airflow-core/src/airflow/dag_processing/manager.py
+++ b/airflow-core/src/airflow/dag_processing/manager.py
@@ -267,6 +267,9 @@ class DagFileProcessorManager(LoggingMixin):
factory=_config_get_factory("dag_processor", "file_parsing_sort_mode")
)
+ _runtime_file_extensions: tuple[str, ...] | None = attrs.field(default=None, init=False)
+ """File extensions registered by runtime coordinators (e.g. ".jar"). Lazily populated."""
+
_api_server: InProcessExecutionAPI = attrs.field(init=False, factory=InProcessExecutionAPI)
"""API server to interact with Metadata DB"""
@@ -815,6 +818,16 @@ def _find_files_in_bundle(self, bundle: BaseDagBundle) -> list[Path]:
return rel_paths
+ def _get_runtime_file_extensions(self) -> tuple[str, ...]:
+ """Collect file extensions from configured runtime coordinators (cached after first call)."""
+ if self._runtime_file_extensions is not None:
+ return self._runtime_file_extensions
+
+ from airflow.sdk.execution_time.coordinator import get_coordinator_manager
+
+ self._runtime_file_extensions = get_coordinator_manager().file_extensions()
+ return self._runtime_file_extensions
+
def _get_observed_filelocs(self, present: set[DagFileInfo]) -> set[str]:
"""
Return observed DAG source paths for bundle entries.
@@ -822,7 +835,11 @@ def _get_observed_filelocs(self, present: set[DagFileInfo]) -> set[str]:
For regular files this includes the relative file path.
For ZIP archives this includes DAG-like inner paths such as
``archive.zip/dag.py``.
+
+ Runtime coordinator file extensions (e.g. ``.jar``) are treated as
+ opaque files rather than ZIP archives.
"""
+ runtime_extensions = self._get_runtime_file_extensions()
def find_zipped_dags(abs_path: os.PathLike) -> Iterator[str]:
"""Yield absolute paths for DAG-like files inside a ZIP archive."""
@@ -837,7 +854,7 @@ def find_zipped_dags(abs_path: os.PathLike) -> Iterator[str]:
observed_filelocs: set[str] = set()
for info in present:
abs_path = str(info.absolute_path)
- if abs_path.endswith(".py") or not zipfile.is_zipfile(abs_path):
+ if abs_path.endswith((".py", *runtime_extensions)) or not zipfile.is_zipfile(abs_path):
observed_filelocs.add(str(info.rel_path))
else:
if TYPE_CHECKING:
diff --git a/airflow-core/src/airflow/dag_processing/processor.py b/airflow-core/src/airflow/dag_processing/processor.py
index aa9f07411f87d..00d17bd390c26 100644
--- a/airflow-core/src/airflow/dag_processing/processor.py
+++ b/airflow-core/src/airflow/dag_processing/processor.py
@@ -17,6 +17,7 @@
from __future__ import annotations
import contextlib
+import functools
import importlib
import logging
import os
@@ -76,8 +77,6 @@
from airflow.utils.state import TaskInstanceState
if TYPE_CHECKING:
- from socket import socket
-
from structlog.typing import FilteringBoundLogger
from airflow.api_fastapi.execution_api.app import InProcessExecutionAPI
@@ -86,6 +85,7 @@
from airflow.sdk.definitions.context import Context
from airflow.sdk.definitions.dag import DAG
from airflow.sdk.definitions.mappedoperator import MappedOperator
+ from airflow.sdk.execution_time.supervisor import SelectorCallback
from airflow.typing_compat import Self
@@ -553,7 +553,14 @@ def start( # type: ignore[override]
) -> Self:
logger = kwargs["logger"]
- _pre_import_airflow_modules(os.fspath(path), logger)
+ # Check if a configured runtime coordinator should handle this file
+ logger.debug("Checking for runtime coordinator entrypoint for file", path=path)
+ resolved_target = cls._resolve_processor_target(path, bundle_name, bundle_path, logger)
+ if resolved_target is not None:
+ target = resolved_target
+ logger.debug("Resolved runtime coordinator entrypoint for file", path=path)
+ else:
+ _pre_import_airflow_modules(os.fspath(path), logger)
proc: Self = super().start(
target=target,
@@ -566,6 +573,35 @@ def start( # type: ignore[override]
proc._on_child_started(callbacks, path, bundle_path, bundle_name)
return proc
+ @staticmethod
+ def _resolve_processor_target(
+ path: str | os.PathLike[str],
+ bundle_name: str,
+ bundle_path: Path,
+ log: FilteringBoundLogger,
+ ) -> Callable[[], None] | None:
+ """
+ Return the entrypoint of the first runtime coordinator that can handle *path*.
+
+ The returned callable is a ``functools.partial`` that binds *path*, *bundle_name*
+ and *bundle_path* so the supervisor can pass it as a no-arg ``target`` to
+ ``WatchedSubprocess.start``.
+ """
+ from airflow.sdk.execution_time.coordinator import get_coordinator_manager
+
+ coordinator = get_coordinator_manager().for_dag_file(bundle_name, path)
+ if coordinator is None:
+ log.debug("No runtime coordinator found for file %s, using default processor", path)
+ return None
+
+ log.debug("Using runtime coordinator %s for file %s", type(coordinator).__qualname__, path)
+ return functools.partial(
+ coordinator.run_dag_parsing,
+ path=os.fspath(path),
+ bundle_name=bundle_name,
+ bundle_path=os.fspath(bundle_path),
+ )
+
def _on_child_started(
self,
callbacks: list[CallbackRequest],
@@ -591,7 +627,7 @@ def _get_target_loggers(self) -> tuple[FilteringBoundLogger, ...]:
def _create_log_forwarder(
self, loggers: tuple[FilteringBoundLogger, ...], name: str, log_level: int = logging.INFO
- ) -> Callable[[socket], bool]:
+ ) -> SelectorCallback:
return super()._create_log_forwarder(loggers, name.replace("task.", "dag_processor.", 1), log_level)
def _handle_request(self, msg: ToManager, log: FilteringBoundLogger, req_id: int) -> None:
diff --git a/airflow-core/src/airflow/executors/base_executor.py b/airflow-core/src/airflow/executors/base_executor.py
index 9c9487c5377bc..bced160a5f4c7 100644
--- a/airflow-core/src/airflow/executors/base_executor.py
+++ b/airflow-core/src/airflow/executors/base_executor.py
@@ -650,11 +650,10 @@ def run_workload(
if isinstance(workload, ExecuteTask):
from airflow.sdk.execution_time.supervisor import supervise_task
+ from airflow.sdk.execution_time.workloads.task import TaskInstanceDTO as SDKTaskInstanceDTO
- # workload.ti is a TaskInstanceDTO which duck-types as TaskInstance.
- # TODO: Create a protocol for this.
return supervise_task(
- ti=workload.ti, # type: ignore[arg-type]
+ ti=SDKTaskInstanceDTO.model_validate(workload.ti, from_attributes=True),
bundle_info=workload.bundle_info,
dag_rel_path=workload.dag_rel_path,
token=workload.token,
diff --git a/airflow-core/src/airflow/executors/workloads/task.py b/airflow-core/src/airflow/executors/workloads/task.py
index d05affe433096..9af3f33c10efd 100644
--- a/airflow-core/src/airflow/executors/workloads/task.py
+++ b/airflow-core/src/airflow/executors/workloads/task.py
@@ -33,8 +33,14 @@
from airflow.models.taskinstancekey import TaskInstanceKey
-class TaskInstanceDTO(BaseModel):
- """Schema for TaskInstance with minimal required fields needed for Executors and Task SDK."""
+class BaseTaskInstanceDTO(BaseModel):
+ """
+ Base schema for TaskInstance with the minimal fields shared by Executors and the Task SDK.
+
+ This definition is duplicated in :mod:`airflow.sdk.execution_time.workloads.task`
+ and the two are kept in sync by the ``check-task-instance-dto-sync`` prek
+ hook. Update both files together.
+ """
id: uuid.UUID
dag_version_id: uuid.UUID
@@ -48,11 +54,16 @@ class TaskInstanceDTO(BaseModel):
queue: str
priority_weight: int
executor_config: dict | None = Field(default=None, exclude=True)
- external_executor_id: str | None = Field(default=None, exclude=True)
parent_context_carrier: dict | None = None
context_carrier: dict | None = None
+
+class TaskInstanceDTO(BaseTaskInstanceDTO):
+ """TaskInstanceDTO with executor-specific ``external_executor_id`` field and ``key`` property."""
+
+ external_executor_id: str | None = Field(default=None, exclude=True)
+
# TODO: Task-SDK: Can we replace TaskInstanceKey with just the uuid across the codebase?
@property
def key(self) -> TaskInstanceKey:
diff --git a/airflow-core/src/airflow/models/dagcode.py b/airflow-core/src/airflow/models/dagcode.py
index 60ee91c8b59b5..cdec5baa95717 100644
--- a/airflow-core/src/airflow/models/dagcode.py
+++ b/airflow-core/src/airflow/models/dagcode.py
@@ -119,6 +119,14 @@ def code(cls, dag_id, session: Session = NEW_SESSION) -> str:
@staticmethod
def get_code_from_file(fileloc):
+ # Try from runtime coordinator first.
+ from airflow.sdk.execution_time.coordinator import get_coordinator_manager
+
+ coordinator = get_coordinator_manager().for_dag_file("", fileloc)
+ if coordinator is not None:
+ return coordinator.get_code_from_file(fileloc)
+
+ # Then fallback to python native
try:
with open_maybe_zipped(fileloc, "r") as f:
code = f.read()
diff --git a/airflow-core/src/airflow/provider.yaml.schema.json b/airflow-core/src/airflow/provider.yaml.schema.json
index 5714b8db658c5..1c41b906289cf 100644
--- a/airflow-core/src/airflow/provider.yaml.schema.json
+++ b/airflow-core/src/airflow/provider.yaml.schema.json
@@ -624,6 +624,13 @@
}
}
},
+ "coordinators": {
+ "type": "array",
+ "description": "Runtime Coordinator class names (BaseCoordinator subclasses)",
+ "items": {
+ "type": "string"
+ }
+ },
"source-date-epoch": {
"type": "integer",
"description": "Source date epoch - seconds since epoch (gmtime) when the release documentation was prepared. Used to generate reproducible package builds with flint.",
diff --git a/airflow-core/src/airflow/provider_info.schema.json b/airflow-core/src/airflow/provider_info.schema.json
index 86fc726a05168..92601fc58af74 100644
--- a/airflow-core/src/airflow/provider_info.schema.json
+++ b/airflow-core/src/airflow/provider_info.schema.json
@@ -446,6 +446,13 @@
"type": "string"
}
}
+ },
+ "coordinators": {
+ "type": "array",
+ "description": "Runtime Coordinator class names (BaseCoordinator subclasses)",
+ "items": {
+ "type": "string"
+ }
}
},
"definitions": {
diff --git a/airflow-core/src/airflow/providers_manager.py b/airflow-core/src/airflow/providers_manager.py
index 6fefcbc39b06d..8945589b4b046 100644
--- a/airflow-core/src/airflow/providers_manager.py
+++ b/airflow-core/src/airflow/providers_manager.py
@@ -41,6 +41,7 @@
if TYPE_CHECKING:
from airflow.cli.cli_config import CLICommand
+ from airflow.sdk.execution_time.coordinator import BaseCoordinator
log = logging.getLogger(__name__)
@@ -448,6 +449,7 @@ def __init__(self):
)
# Set of plugins contained in providers
self._plugins_set: set[PluginInfo] = set()
+ self._coordinators: list[type[BaseCoordinator]] = []
self._init_airflow_core_hooks()
self._runtime_manager = None
@@ -625,6 +627,12 @@ def initialize_providers_configuration(self):
self.initialize_providers_list()
self._discover_config()
+ @provider_info_cache("coordinators")
+ def initialize_providers_coordinators(self):
+ """Lazy initialization of providers runtime coordinators."""
+ self.initialize_providers_list()
+ self._discover_coordinators()
+
@provider_info_cache("plugins")
def initialize_providers_plugins(self):
self.initialize_providers_list()
@@ -1280,6 +1288,19 @@ def _discover_config(self) -> None:
if provider.data.get("config"):
self._provider_configs[provider_package] = provider.data.get("config") # type: ignore[assignment]
+ def _discover_coordinators(self) -> None:
+ """Retrieve and pre-load all coordinators defined in the providers."""
+ seen: set[str] = set()
+ for provider_package, provider in self._provider_dict.items():
+ for coordinator_class_path in provider.data.get("coordinators", []):
+ if coordinator_class_path in seen:
+ continue
+ coordinator_cls = _correctness_check(provider_package, coordinator_class_path, provider)
+ if coordinator_cls:
+ seen.add(coordinator_class_path)
+ self._coordinators.append(coordinator_cls)
+ self._coordinators = sorted(self._coordinators, key=lambda c: c.__qualname__)
+
def _discover_plugins(self) -> None:
"""Retrieve all plugins defined in the providers."""
for provider_package, provider in self._provider_dict.items():
@@ -1477,6 +1498,12 @@ def db_managers(self) -> list[str]:
self.initialize_providers_db_managers()
return sorted(self._db_manager_class_name_set)
+ @property
+ def coordinators(self) -> list[type[BaseCoordinator]]:
+ """Returns pre-loaded coordinator classes available in providers."""
+ self.initialize_providers_coordinators()
+ return self._coordinators
+
@property
def filesystem_module_names(self) -> list[str]:
self.initialize_providers_filesystems()
@@ -1548,6 +1575,7 @@ def _cleanup(self):
self._trigger_info_set.clear()
self._notification_info_set.clear()
self._plugins_set.clear()
+ self._coordinators.clear()
self._cli_command_functions_set.clear()
self._cli_command_provider_name_set.clear()
diff --git a/airflow-core/src/airflow/serialization/definitions/baseoperator.py b/airflow-core/src/airflow/serialization/definitions/baseoperator.py
index 6bafc5891235a..9eaf9cc3ed906 100644
--- a/airflow-core/src/airflow/serialization/definitions/baseoperator.py
+++ b/airflow-core/src/airflow/serialization/definitions/baseoperator.py
@@ -195,6 +195,7 @@ def get_serialized_fields(cls):
"ignore_first_depends_on_past",
"inlets",
"is_setup",
+ "sdk",
"is_teardown",
"map_index_template",
"max_active_tis_per_dag",
diff --git a/airflow-core/tests/unit/always/test_providers_manager.py b/airflow-core/tests/unit/always/test_providers_manager.py
index afa473e80a4f0..b13930c98d1c2 100644
--- a/airflow-core/tests/unit/always/test_providers_manager.py
+++ b/airflow-core/tests/unit/always/test_providers_manager.py
@@ -258,6 +258,34 @@ def test_dialects(self):
assert len(dialect_class_names) == 3
assert dialect_class_names == ["default", "mssql", "postgresql"]
+ @patch("airflow.providers_manager.import_string")
+ def test_coordinators(self, mock_import_string):
+ class ACoordinator:
+ pass
+
+ class ZCoordinator:
+ pass
+
+ mock_import_string.side_effect = lambda path: {
+ "airflow.providers.sdk.java.coordinator.ACoordinator": ACoordinator,
+ "airflow.providers.sdk.java.coordinator.ZCoordinator": ZCoordinator,
+ }[path]
+ providers_manager = ProvidersManager()
+ providers_manager._provider_dict = LazyDictWithCache()
+ providers_manager._provider_dict["apache-airflow-providers-sdk-java"] = ProviderInfo(
+ version="0.0.1",
+ data={
+ "coordinators": [
+ "airflow.providers.sdk.java.coordinator.ZCoordinator",
+ "airflow.providers.sdk.java.coordinator.ACoordinator",
+ "airflow.providers.sdk.java.coordinator.ZCoordinator",
+ ]
+ },
+ )
+
+ with patch.object(providers_manager, "initialize_providers_list"):
+ assert providers_manager.coordinators == [ACoordinator, ZCoordinator]
+
class TestWithoutCheckProviderManager:
@pytest.fixture(autouse=True)
diff --git a/airflow-e2e-tests/docker/java-sdk.yml b/airflow-e2e-tests/docker/java-sdk.yml
new file mode 100644
index 0000000000000..27a489c02122d
--- /dev/null
+++ b/airflow-e2e-tests/docker/java-sdk.yml
@@ -0,0 +1,113 @@
+# 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.
+#
+# Docker Compose override for Java SDK e2e tests.
+#
+# Overrides the dag-processor and worker services and adds a dedicated
+# Java queue worker to:
+# 1. Mount a pre-downloaded JDK, the Python stub bundle, and the pure Java bundle.
+# 2. Set JAVA_HOME and PATH so the Java coordinator can find the JDK.
+#
+# The apache-airflow-coordinators-java distribution is expected to be
+# pre-installed in the Docker image (baked in by `breeze prod-image build`
+# via the workspace `uv sync --all-packages`).
+#
+# Environment variables expected (set by conftest.py):
+# JAVA_OPENJDK_PATH – host path to the pre-downloaded OpenJDK directory
+# JAVA_STUB_BUNDLE_PATH – host path to the stub DAG bundle root (dags/ + jar-bundles/)
+# JAVA_BUNDLES_PATH – host path to the pure Java DAG bundle (JARs only)
+---
+x-java-env: &java-env
+ AIRFLOW__CORE__LOAD_EXAMPLES: "false"
+ JAVA_HOME: /files/openjdk
+ PATH: /files/openjdk/bin:/home/airflow/.local/bin:/usr/python/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
+
+x-java-volumes: &java-volumes
+ - ${JAVA_OPENJDK_PATH}:/files/openjdk
+ - ${JAVA_STUB_BUNDLE_PATH}:/files/python-stub-java-tasks
+ - ${JAVA_BUNDLES_PATH}:/files/pure-java
+
+services:
+ airflow-dag-processor:
+ user: "0:0"
+ environment:
+ <<: *java-env
+ volumes: *java-volumes
+
+ airflow-worker:
+ user: "0:0"
+ environment:
+ <<: *java-env
+ volumes: *java-volumes
+
+ # Dedicated Celery worker that only consumes from "java-queue".
+ # Since this is a new service (not an override of the base docker-compose),
+ # it needs the full service definition including the common Airflow config
+ # that the base compose provides via x-airflow-common.
+ # AIRFLOW__SDK__COORDINATORS and AIRFLOW__SDK__QUEUE_TO_COORDINATOR are picked
+ # up from the .env file via env_file (set by conftest.py).
+ airflow-java-worker:
+ image: ${AIRFLOW_IMAGE_NAME:-apache/airflow:|version|}
+ env_file:
+ - ${ENV_FILE_PATH:-.env}
+ command: celery worker --queues java-queue
+ user: "0:0"
+ environment:
+ <<: *java-env
+ # Mirrors x-airflow-common-env from the base docker-compose.yaml
+ AIRFLOW__CORE__EXECUTOR: CeleryExecutor
+ AIRFLOW__CORE__AUTH_MANAGER: airflow.providers.fab.auth_manager.fab_auth_manager.FabAuthManager
+ AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://airflow:airflow@postgres/airflow
+ AIRFLOW__CELERY__RESULT_BACKEND: db+postgresql+psycopg2://airflow:airflow@postgres/airflow
+ AIRFLOW__CELERY__BROKER_URL: redis://:@redis:6379/0
+ AIRFLOW__CORE__FERNET_KEY: ${FERNET_KEY}
+ AIRFLOW__CORE__DAGS_ARE_PAUSED_AT_CREATION: 'true'
+ AIRFLOW__CORE__EXECUTION_API_SERVER_URL: 'http://airflow-apiserver:8080/execution/'
+ AIRFLOW__API_AUTH__JWT_SECRET: ${AIRFLOW__API_AUTH__JWT_SECRET:-airflow_jwt_secret}
+ AIRFLOW__API_AUTH__JWT_ISSUER: ${AIRFLOW__API_AUTH__JWT_ISSUER:-airflow}
+ AIRFLOW__SCHEDULER__ENABLE_HEALTH_CHECK: 'true'
+ _PIP_ADDITIONAL_REQUIREMENTS: ${_PIP_ADDITIONAL_REQUIREMENTS:-}
+ AIRFLOW_CONFIG: '/opt/airflow/config/airflow.cfg'
+ # Required to handle warm shutdown of the celery workers properly
+ # See https://airflow.apache.org/docs/docker-stack/entrypoint.html#signal-propagation
+ DUMB_INIT_SETSID: "0"
+ volumes:
+ - ${AIRFLOW_PROJ_DIR:-.}/dags:/opt/airflow/dags
+ - ${AIRFLOW_PROJ_DIR:-.}/logs:/opt/airflow/logs
+ - ${AIRFLOW_PROJ_DIR:-.}/config:/opt/airflow/config
+ - ${AIRFLOW_PROJ_DIR:-.}/plugins:/opt/airflow/plugins
+ - ${JAVA_OPENJDK_PATH}:/files/openjdk
+ - ${JAVA_STUB_BUNDLE_PATH}:/files/python-stub-java-tasks
+ - ${JAVA_BUNDLES_PATH}:/files/pure-java
+ healthcheck:
+ # yamllint disable rule:line-length
+ test: ["CMD-SHELL", 'celery --app airflow.providers.celery.executors.celery_executor.app inspect ping -d "celery@$${HOSTNAME}" || celery --app airflow.executors.celery_executor.app inspect ping -d "celery@$${HOSTNAME}"']
+ # yamllint enable rule:line-length
+ interval: 30s
+ timeout: 10s
+ retries: 5
+ start_period: 30s
+ restart: always
+ depends_on:
+ redis:
+ condition: service_healthy
+ postgres:
+ condition: service_healthy
+ airflow-apiserver:
+ condition: service_healthy
+ airflow-init:
+ condition: service_completed_successfully
diff --git a/airflow-e2e-tests/tests/airflow_e2e_tests/conftest.py b/airflow-e2e-tests/tests/airflow_e2e_tests/conftest.py
index 4443f7d8a2898..431a88c430a01 100644
--- a/airflow-e2e-tests/tests/airflow_e2e_tests/conftest.py
+++ b/airflow-e2e-tests/tests/airflow_e2e_tests/conftest.py
@@ -18,6 +18,9 @@
import json
import os
+import platform
+import subprocess
+import tempfile
from datetime import datetime
from pathlib import Path
from shutil import copyfile, copytree
@@ -27,6 +30,7 @@
from testcontainers.compose import DockerCompose
from airflow_e2e_tests.constants import (
+ AIRFLOW_ROOT_PATH,
AWS_INIT_PATH,
DOCKER_COMPOSE_HOST_PORT,
DOCKER_COMPOSE_PATH,
@@ -34,6 +38,16 @@
E2E_DAGS_FOLDER,
E2E_TEST_MODE,
ELASTICSEARCH_PATH,
+ JAVA_CONTAINER_PURE_BUNDLE_PATH,
+ JAVA_CONTAINER_STUB_DAG_BUNDLE_PATH,
+ JAVA_CONTAINER_STUB_JAVA_BUNDLES_FOLDER_PATH,
+ JAVA_PURE_BUNDLE_NAME,
+ JAVA_PURE_DAG_ID,
+ JAVA_SDK_COMPOSE_PATH,
+ JAVA_SDK_DAGS_FOLDER,
+ JAVA_SDK_PATH,
+ JAVA_STUB_BUNDLE_NAME,
+ JAVA_STUB_DAG_ID,
LOCALSTACK_PATH,
LOGS_FOLDER,
OPENSEARCH_PATH,
@@ -142,8 +156,152 @@ def _setup_xcom_object_storage_integration(dot_env_file, tmp_dir):
os.environ["ENV_FILE_PATH"] = str(dot_env_file)
-def spin_up_airflow_environment(tmp_path_factory: pytest.TempPathFactory):
- tmp_dir = tmp_path_factory.mktemp("airflow-e2e-tests")
+def _download_jdk(dest_dir: Path) -> Path:
+ """Download a Linux JDK matching the host/container architecture into dest_dir/openjdk."""
+ machine = platform.machine()
+ if machine in ("x86_64", "amd64"):
+ arch = "x64"
+ elif machine in ("aarch64", "arm64"):
+ arch = "aarch64"
+ else:
+ raise RuntimeError(f"Unsupported architecture: {machine}")
+
+ jdk_url = (
+ "https://github.com/adoptium/temurin11-binaries/releases/download/"
+ f"jdk-11.0.30%2B7/OpenJDK11U-jdk_{arch}_linux_hotspot_11.0.30_7.tar.gz"
+ )
+
+ tarball_path = dest_dir / "openjdk-11.tar.gz"
+ openjdk_dir = dest_dir / "openjdk"
+ openjdk_dir.mkdir(exist_ok=True)
+
+ console.print(f"[yellow]Downloading OpenJDK 11 ({arch}) for containers...")
+ result = subprocess.run(
+ ["curl", "-fL", "-o", str(tarball_path), jdk_url],
+ capture_output=True,
+ text=True,
+ timeout=300,
+ check=False,
+ )
+ if result.returncode != 0:
+ raise RuntimeError(f"Failed to download OpenJDK: {result.stderr}")
+
+ console.print("[yellow]Extracting OpenJDK...")
+ result = subprocess.run(
+ ["tar", "-xzf", str(tarball_path), "--strip-components=1", "-C", str(openjdk_dir)],
+ capture_output=True,
+ text=True,
+ timeout=120,
+ check=False,
+ )
+ if result.returncode != 0:
+ raise RuntimeError(f"Failed to extract OpenJDK: {result.stderr}")
+
+ tarball_path.unlink()
+ console.print("[green]OpenJDK 11 downloaded and extracted successfully")
+ return openjdk_dir
+
+
+def _build_java_sdk_bundles(tmp_dir: Path) -> Path:
+ """Build both Java SDK bundles (stub + pure Java) using the shared build script.
+
+ Returns the bundles output directory containing both bundle subdirectories.
+ The caller's system Java is used for the Gradle build (not the downloaded Linux JDK).
+ """
+ build_script = AIRFLOW_ROOT_PATH / "scripts" / "in_container" / "java_sdk_build.sh"
+ bundles_dir = tmp_dir / "java-sdk-bundles"
+
+ build_env = {
+ **os.environ,
+ "JAVA_SDK_SRC_DIR": str(JAVA_SDK_PATH),
+ "BUNDLES_OUTPUT_DIR": str(bundles_dir),
+ "JAVA_STUB_BUNDLE_NAME": JAVA_STUB_BUNDLE_NAME,
+ "JAVA_PURE_BUNDLE_NAME": JAVA_PURE_BUNDLE_NAME,
+ "JAVA_STUB_DAG_ID": JAVA_STUB_DAG_ID,
+ "JAVA_PURE_DAG_ID": JAVA_PURE_DAG_ID,
+ "JAVA_SDK_DAGS_DIR": str(JAVA_SDK_DAGS_FOLDER),
+ # The build script defaults GRADLE_USER_HOME to /files/.gradle for breeze
+ # in-container caching; on the host we use the standard ~/.gradle so the
+ # Gradle wrapper can write its lock files.
+ "GRADLE_USER_HOME": os.environ.get("GRADLE_USER_HOME", str(Path.home() / ".gradle")),
+ }
+
+ console.print("[yellow]Building Java SDK bundles via shared build script...")
+ result = subprocess.run(
+ ["bash", str(build_script)],
+ env=build_env,
+ capture_output=True,
+ text=True,
+ timeout=600,
+ check=False,
+ )
+ if result.returncode != 0:
+ console.print(f"[red]Java SDK build failed:\n{result.stdout}\n{result.stderr}")
+ raise RuntimeError("Failed to build Java SDK bundles")
+ console.print(result.stdout)
+
+ return bundles_dir
+
+
+def _setup_java_sdk_integration(dot_env_file, tmp_dir):
+ """Set up Java SDK integration: download JDK, build bundles, write env and compose override."""
+ # Download the Linux JDK on the host so containers get it via bind mount
+ openjdk_dir = _download_jdk(tmp_dir)
+
+ # Build both bundles (stub + pure Java) using the shared script
+ bundles_dir = _build_java_sdk_bundles(tmp_dir)
+ stub_bundle_root = bundles_dir / JAVA_STUB_BUNDLE_NAME
+ pure_bundle_dir = bundles_dir / JAVA_PURE_BUNDLE_NAME
+
+ # Copy the docker-compose override
+ copyfile(JAVA_SDK_COMPOSE_PATH, tmp_dir / "java-sdk.yml")
+
+ # Set host environment variables consumed by docker-compose variable substitution
+ os.environ["JAVA_OPENJDK_PATH"] = str(openjdk_dir)
+ os.environ["JAVA_STUB_BUNDLE_PATH"] = str(stub_bundle_root)
+ os.environ["JAVA_BUNDLES_PATH"] = str(pure_bundle_dir)
+
+ # Write .env file with bundle config for both DAG bundles
+ bundle_config = json.dumps(
+ [
+ {
+ "name": JAVA_STUB_BUNDLE_NAME,
+ "classpath": "airflow.dag_processing.bundles.local.LocalDagBundle",
+ "kwargs": {"path": JAVA_CONTAINER_STUB_DAG_BUNDLE_PATH, "refresh_interval": 20},
+ },
+ {
+ "name": JAVA_PURE_BUNDLE_NAME,
+ "classpath": "airflow.dag_processing.bundles.local.LocalDagBundle",
+ "kwargs": {"path": JAVA_CONTAINER_PURE_BUNDLE_PATH, "refresh_interval": 20},
+ },
+ ]
+ )
+ coordinators = json.dumps(
+ [
+ {
+ "name": "java",
+ "classpath": "airflow.sdk.coordinators.java.JavaCoordinator",
+ "kwargs": {"bundles_folder": JAVA_CONTAINER_STUB_JAVA_BUNDLES_FOLDER_PATH},
+ }
+ ]
+ )
+ queue_to_coordinator = json.dumps({"java-queue": "java"})
+
+ dot_env_file.write_text(
+ f"AIRFLOW_UID={os.getuid()}\n"
+ "AIRFLOW__CORE__LOAD_EXAMPLES=false\n"
+ "AIRFLOW__LOGGING__LOGGING_LEVEL=DEBUG\n"
+ f"AIRFLOW__SDK__COORDINATORS={coordinators}\n"
+ f"AIRFLOW__SDK__QUEUE_TO_COORDINATOR={queue_to_coordinator}\n"
+ f"AIRFLOW__DAG_PROCESSOR__DAG_BUNDLE_CONFIG_LIST={bundle_config}\n"
+ )
+ os.environ["ENV_FILE_PATH"] = str(dot_env_file)
+
+
+def spin_up_airflow_environment():
+ # We indent to use explicitly created temp directory instead of pytest's tmp_path fixture because we want the directory to persist after the test run for debugging purposes and pytest's tmp_path is automatically deleted after the test run.
+ tmp_dir = Path(tempfile.mkdtemp(prefix="airflow-e2e-tests-"))
+ console.print(f"[yellow]Temp directory (persists after test run): {tmp_dir}")
console.print(f"[yellow]Using docker compose file: {DOCKER_COMPOSE_PATH}")
copyfile(DOCKER_COMPOSE_PATH, tmp_dir / "docker-compose.yaml")
@@ -180,6 +338,9 @@ def spin_up_airflow_environment(tmp_path_factory: pytest.TempPathFactory):
elif E2E_TEST_MODE == "xcom_object_storage":
compose_file_names.append("localstack.yml")
_setup_xcom_object_storage_integration(dot_env_file, tmp_dir)
+ elif E2E_TEST_MODE == "java_sdk":
+ compose_file_names.append("java-sdk.yml")
+ _setup_java_sdk_integration(dot_env_file, tmp_dir)
#
# Please Do not use this Fernet key in any deployments! Please generate your own key.
@@ -224,8 +385,7 @@ def _print_logs(compose_instance: DockerCompose):
def pytest_sessionstart(session: pytest.Session):
- tmp_path_factory = session.config._tmp_path_factory
- spin_up_airflow_environment(tmp_path_factory)
+ spin_up_airflow_environment()
console.print("[green]Airflow environment is up and running!")
diff --git a/airflow-e2e-tests/tests/airflow_e2e_tests/constants.py b/airflow-e2e-tests/tests/airflow_e2e_tests/constants.py
index a2d99aeababa1..9c7c05c537eea 100644
--- a/airflow-e2e-tests/tests/airflow_e2e_tests/constants.py
+++ b/airflow-e2e-tests/tests/airflow_e2e_tests/constants.py
@@ -47,3 +47,24 @@
# s3 bucket name for XComObjectStorageBackend tests. This bucket will be created in the `init-aws.sh` script that is run as part of the LocalStack container initialization.
XCOM_BUCKET = "test-xcom-objectstorage-backend"
+
+# Java SDK e2e test paths
+JAVA_SDK_PATH = AIRFLOW_ROOT_PATH / "java-sdk"
+JAVA_SDK_COMPOSE_PATH = AIRFLOW_ROOT_PATH / "airflow-e2e-tests" / "docker" / "java-sdk.yml"
+JAVA_SDK_DAGS_FOLDER = (
+ AIRFLOW_ROOT_PATH / "airflow-e2e-tests" / "tests" / "airflow_e2e_tests" / "java_sdk_tests" / "dags"
+)
+# Java SDK bundle names (used in DAG bundle config and docker-compose env vars)
+JAVA_STUB_BUNDLE_NAME = "python-stub-java-tasks"
+JAVA_PURE_BUNDLE_NAME = "pure-java"
+
+# Java SDK DAG IDs (sed-replaced from the original "java_example" in JavaExample.java / stub_dag.py)
+JAVA_STUB_DAG_ID = "java_sdk_stub_example"
+JAVA_PURE_DAG_ID = "java_sdk_pure_java_example"
+
+# In-container paths for Java SDK volumes (mounted by java-sdk.yml)
+JAVA_CONTAINER_OPENJDK_PATH = "/files/openjdk"
+JAVA_CONTAINER_STUB_BUNDLE_PATH = f"/files/{JAVA_STUB_BUNDLE_NAME}"
+JAVA_CONTAINER_STUB_DAG_BUNDLE_PATH = f"/files/{JAVA_STUB_BUNDLE_NAME}/dags"
+JAVA_CONTAINER_STUB_JAVA_BUNDLES_FOLDER_PATH = f"/files/{JAVA_STUB_BUNDLE_NAME}/jar-bundles"
+JAVA_CONTAINER_PURE_BUNDLE_PATH = f"/files/{JAVA_PURE_BUNDLE_NAME}"
diff --git a/airflow-e2e-tests/tests/airflow_e2e_tests/java_sdk_tests/__init__.py b/airflow-e2e-tests/tests/airflow_e2e_tests/java_sdk_tests/__init__.py
new file mode 100644
index 0000000000000..13a83393a9124
--- /dev/null
+++ b/airflow-e2e-tests/tests/airflow_e2e_tests/java_sdk_tests/__init__.py
@@ -0,0 +1,16 @@
+# 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.
diff --git a/airflow-e2e-tests/tests/airflow_e2e_tests/java_sdk_tests/dags/stub_dag.py b/airflow-e2e-tests/tests/airflow_e2e_tests/java_sdk_tests/dags/stub_dag.py
new file mode 100644
index 0000000000000..5bc41d1001640
--- /dev/null
+++ b/airflow-e2e-tests/tests/airflow_e2e_tests/java_sdk_tests/dags/stub_dag.py
@@ -0,0 +1,51 @@
+# 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
+
+from airflow.sdk import dag, task
+
+
+@task()
+def python_task_1(ti):
+ print("python_task_1")
+ print("Push Python Task 'python_task_1' XCom:")
+ ti.xcom_push(value="value-pushed-from-python_task_1", key="return_value")
+
+
+@task.stub(queue="java-queue")
+def extract(): ...
+
+
+@task.stub(queue="java-queue")
+def transform(): ...
+
+
+@task()
+def python_task_2(ti):
+ print("python_task_2")
+ print("Pull Java Task 'transform' XCom:")
+ print(ti.xcom_pull(task_ids="transform"))
+
+
+@dag(dag_id="java_example")
+def simple_dag():
+
+ python_task_1() >> extract() >> transform() >> python_task_2()
+
+
+simple_dag()
diff --git a/airflow-e2e-tests/tests/airflow_e2e_tests/java_sdk_tests/test_java_sdk.py b/airflow-e2e-tests/tests/airflow_e2e_tests/java_sdk_tests/test_java_sdk.py
new file mode 100644
index 0000000000000..8b30f25c1f8b3
--- /dev/null
+++ b/airflow-e2e-tests/tests/airflow_e2e_tests/java_sdk_tests/test_java_sdk.py
@@ -0,0 +1,237 @@
+# 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
+
+from datetime import datetime, timezone
+
+import pytest
+import requests
+
+from airflow_e2e_tests.constants import JAVA_PURE_DAG_ID, JAVA_STUB_DAG_ID
+from airflow_e2e_tests.e2e_test_utils.clients import AirflowClient
+
+
+class TestJavaSDK:
+ """End-to-end tests for the Java SDK integration.
+
+ Tests two DAGs that exercise the Java SDK:
+
+ 1. Python stub DAG (stub_dag.py):
+ python_task_1 -> extract (Java) -> transform (Java) -> python_task_2
+ Validates Python-Java interop, XCom flow in both directions, getConnection, getVariable.
+
+ 2. Pure Java DAG (JavaExample.java):
+ extract -> transform -> load
+ Validates pure Java DAG parsing. The load task throws RuntimeException("I failed").
+ """
+
+ airflow_client = AirflowClient()
+
+ @pytest.fixture(autouse=True)
+ def _create_prerequisites(self):
+ """Create the Airflow connection and variable that the Java tasks depend on."""
+ try:
+ self.airflow_client._make_request(
+ method="POST",
+ endpoint="connections",
+ json={
+ "connection_id": "test_http",
+ "conn_type": "http",
+ "host": "httpbin.org",
+ },
+ )
+ except requests.exceptions.HTTPError as e:
+ if e.response.status_code != 409:
+ raise
+
+ try:
+ self.airflow_client._make_request(
+ method="POST",
+ endpoint="variables",
+ json={
+ "key": "my_variable",
+ "value": "test_value",
+ },
+ )
+ except requests.exceptions.HTTPError as e:
+ if e.response.status_code != 409:
+ raise
+
+ def test_stub_java_dag(self):
+ """Trigger the Python stub DAG once and validate all Python-Java interop.
+
+ stub_dag.py defines: python_task_1 -> extract -> transform -> python_task_2
+ All 4 tasks should succeed.
+ """
+ resp = self.airflow_client.trigger_dag(
+ JAVA_STUB_DAG_ID,
+ json={"logical_date": datetime.now(timezone.utc).isoformat()},
+ )
+ run_id = resp["dag_run_id"]
+ state = self.airflow_client.wait_for_dag_run(
+ dag_id=JAVA_STUB_DAG_ID,
+ run_id=run_id,
+ timeout=300,
+ )
+
+ assert state == "success", f"Stub DAG run expected to succeed, got: {state}"
+
+ # Validate all 4 task instances succeed
+ task_instances = self.airflow_client.get_task_instances(JAVA_STUB_DAG_ID, run_id)
+ ti_map = {ti["task_id"]: ti["state"] for ti in task_instances["task_instances"]}
+
+ expected_tasks = {"python_task_1", "extract", "transform", "python_task_2"}
+ assert expected_tasks.issubset(ti_map.keys()), (
+ f"Missing tasks. Expected {expected_tasks}, got {set(ti_map)}"
+ )
+ for task_id in expected_tasks:
+ assert ti_map[task_id] == "success", (
+ f"Task '{task_id}' expected 'success', got '{ti_map[task_id]}'"
+ )
+
+ # Validate Extract task logs
+ extract_logs = str(
+ self.airflow_client.get_task_logs(
+ dag_id=JAVA_STUB_DAG_ID, run_id=run_id, task_id="extract", try_number=1
+ )
+ )
+ assert "Hello from task" in extract_logs, "Expected 'Hello from task' in extract logs"
+ assert "Got XCom from Python Task 'python_task_1'" in extract_logs, (
+ "Extract task did not log receiving XCom from python_task_1"
+ )
+ assert "Got con" in extract_logs, "Extract task did not log getting connection"
+ assert "Goodbye from task" in extract_logs, "Expected 'Goodbye from task' in extract logs"
+
+ # Validate Transform task logs
+ transform_logs = str(
+ self.airflow_client.get_task_logs(
+ dag_id=JAVA_STUB_DAG_ID, run_id=run_id, task_id="transform", try_number=1
+ )
+ )
+ assert "Got XCom from 'extract'" in transform_logs, (
+ "Transform task did not log receiving XCom from extract"
+ )
+ assert "Got variable" in transform_logs, "Transform task did not log getting variable"
+ assert "Push XCom to python task 2" in transform_logs, "Transform task did not log pushing XCom"
+
+ # Validate XCom flow: Java -> Python (transform -> python_task_2)
+ python_task_2_logs = str(
+ self.airflow_client.get_task_logs(
+ dag_id=JAVA_STUB_DAG_ID, run_id=run_id, task_id="python_task_2", try_number=1
+ )
+ )
+ assert "Pull Java Task" in python_task_2_logs, (
+ "python_task_2 did not log pulling XCom from Java transform task"
+ )
+
+ # Validate XCom values via API
+ python_task_1_xcom = self.airflow_client.get_xcom_value(
+ dag_id=JAVA_STUB_DAG_ID, run_id=run_id, task_id="python_task_1", key="return_value"
+ )
+ assert python_task_1_xcom["value"] == "value-pushed-from-python_task_1", (
+ f"python_task_1 XCom expected 'value-pushed-from-python_task_1', got: {python_task_1_xcom['value']}"
+ )
+
+ # Java extract and transform tasks push a timestamp (long) via client.setXCom()
+ extract_xcom = self.airflow_client.get_xcom_value(
+ dag_id=JAVA_STUB_DAG_ID, run_id=run_id, task_id="extract", key="return_value"
+ )
+ assert extract_xcom["value"] is not None, "extract XCom value should not be None"
+
+ transform_xcom = self.airflow_client.get_xcom_value(
+ dag_id=JAVA_STUB_DAG_ID, run_id=run_id, task_id="transform", key="return_value"
+ )
+ assert transform_xcom["value"] is not None, "transform XCom value should not be None"
+
+ def test_pure_java_dag(self):
+ """Trigger the pure Java DAG once and validate JavaExample.java behaviors.
+
+ JavaExample.java defines: extract -> transform -> load
+ extract and transform succeed; load throws RuntimeException("I failed").
+ """
+ resp = self.airflow_client.trigger_dag(
+ JAVA_PURE_DAG_ID,
+ json={"logical_date": datetime.now(timezone.utc).isoformat()},
+ )
+ run_id = resp["dag_run_id"]
+ state = self.airflow_client.wait_for_dag_run(
+ dag_id=JAVA_PURE_DAG_ID,
+ run_id=run_id,
+ timeout=300,
+ )
+
+ # DAG run should fail because load throws RuntimeException
+ assert state == "failed", (
+ f"Pure Java DAG expected to fail (load throws RuntimeException), got: {state}"
+ )
+
+ # Validate 3 task instances with expected states
+ task_instances = self.airflow_client.get_task_instances(JAVA_PURE_DAG_ID, run_id)
+ ti_map = {ti["task_id"]: ti["state"] for ti in task_instances["task_instances"]}
+
+ expected_states = {
+ "extract": "success",
+ "transform": "success",
+ "load": "failed",
+ }
+ assert set(expected_states).issubset(ti_map.keys()), (
+ f"Missing tasks. Expected {set(expected_states)}, got {set(ti_map)}"
+ )
+ for task_id, expected_state in expected_states.items():
+ assert ti_map[task_id] == expected_state, (
+ f"Task '{task_id}' expected '{expected_state}', got '{ti_map[task_id]}'"
+ )
+
+ # Validate Extract task logs
+ extract_logs = str(
+ self.airflow_client.get_task_logs(
+ dag_id=JAVA_PURE_DAG_ID, run_id=run_id, task_id="extract", try_number=1
+ )
+ )
+ assert "Hello from task" in extract_logs, "Expected 'Hello from task' in extract logs"
+ assert "Goodbye from task" in extract_logs, "Expected 'Goodbye from task' in extract logs"
+
+ # Validate Transform task logs
+ transform_logs = str(
+ self.airflow_client.get_task_logs(
+ dag_id=JAVA_PURE_DAG_ID, run_id=run_id, task_id="transform", try_number=1
+ )
+ )
+ assert "Got XCom from 'extract'" in transform_logs, (
+ "Transform task did not log receiving XCom from extract"
+ )
+ assert "Got variable" in transform_logs, "Transform task did not log getting variable"
+
+ # Validate Load task logs (expected failure)
+ load_logs = str(
+ self.airflow_client.get_task_logs(
+ dag_id=JAVA_PURE_DAG_ID, run_id=run_id, task_id="load", try_number=1
+ )
+ )
+ assert "Got XCom from 'transform'" in load_logs, "Load task did not log receiving XCom from transform"
+ assert "I failed" in load_logs, "Load task did not log the expected RuntimeException message"
+
+ # Validate XCom values via API for successful tasks
+ extract_xcom = self.airflow_client.get_xcom_value(
+ dag_id=JAVA_PURE_DAG_ID, run_id=run_id, task_id="extract", key="return_value"
+ )
+ assert extract_xcom["value"] is not None, "extract XCom value should not be None"
+
+ transform_xcom = self.airflow_client.get_xcom_value(
+ dag_id=JAVA_PURE_DAG_ID, run_id=run_id, task_id="transform", key="return_value"
+ )
+ assert transform_xcom["value"] is not None, "transform XCom value should not be None"
diff --git a/dev/breeze/src/airflow_breeze/commands/common_options.py b/dev/breeze/src/airflow_breeze/commands/common_options.py
index 12d4b3f40d35e..cdfb1eb587d11 100644
--- a/dev/breeze/src/airflow_breeze/commands/common_options.py
+++ b/dev/breeze/src/airflow_breeze/commands/common_options.py
@@ -32,6 +32,7 @@
ALLOWED_MYSQL_VERSIONS,
ALLOWED_POSTGRES_VERSIONS,
ALLOWED_PYTHON_MAJOR_MINOR_VERSIONS,
+ ALLOWED_SDKS,
ALLOWED_TERMINAL_MULTIPLEXERS,
ALLOWED_TTY,
ALLOWED_USE_AIRFLOW_VERSIONS,
@@ -611,6 +612,14 @@ def _normalize_platform(ctx: click.core.Context, param: click.core.Option, value
envvar="WORKER_TYPE",
)
+option_sdk = click.option(
+ "--sdk",
+ help="Enable an SDK integration (installs dependencies, compiles the SDK, and configures DAG bundles).",
+ type=BetterChoice(ALLOWED_SDKS),
+ multiple=True,
+ envvar="SDK",
+)
+
# UI E2E Testing Options
option_airflow_ui_base_url = click.option(
diff --git a/dev/breeze/src/airflow_breeze/commands/developer_commands.py b/dev/breeze/src/airflow_breeze/commands/developer_commands.py
index d1d9b30d2699a..30741f91af1db 100644
--- a/dev/breeze/src/airflow_breeze/commands/developer_commands.py
+++ b/dev/breeze/src/airflow_breeze/commands/developer_commands.py
@@ -69,6 +69,7 @@
option_project_name,
option_python,
option_run_db_tests_only,
+ option_sdk,
option_skip_db_tests,
option_standalone_dag_processor,
option_terminal_multiplexer,
@@ -577,6 +578,7 @@ def shell(
@option_providers_skip_constraints
@option_python
@option_restart
+@option_sdk
@option_standalone_dag_processor
@option_terminal_multiplexer
@option_use_uv
@@ -627,6 +629,7 @@ def start_airflow(
providers_skip_constraints: bool,
python: str,
restart: bool,
+ sdk: tuple[str, ...],
skip_assets_compilation: bool,
standalone_dag_processor: bool,
terminal_multiplexer: str,
@@ -732,6 +735,7 @@ def start_airflow(
providers_skip_constraints=providers_skip_constraints,
python=python,
restart=restart,
+ sdk=sdk,
skip_assets_compilation=skip_assets_compilation,
standalone_dag_processor=standalone_dag_processor,
start_airflow=True,
diff --git a/dev/breeze/src/airflow_breeze/global_constants.py b/dev/breeze/src/airflow_breeze/global_constants.py
index 4deaa3bf598b9..fdc09cb04981f 100644
--- a/dev/breeze/src/airflow_breeze/global_constants.py
+++ b/dev/breeze/src/airflow_breeze/global_constants.py
@@ -196,6 +196,9 @@
GOLANG_WORKER = "go"
+JAVA_SDK = "java"
+ALLOWED_SDKS = [JAVA_SDK]
+
DEFAULT_ALLOWED_EXECUTOR = ALLOWED_EXECUTORS[0]
ALLOWED_AUTH_MANAGERS = [SIMPLE_AUTH_MANAGER, FAB_AUTH_MANAGER]
START_AIRFLOW_ALLOWED_EXECUTORS = [LOCAL_EXECUTOR, CELERY_EXECUTOR, EDGE_EXECUTOR]
diff --git a/dev/breeze/src/airflow_breeze/params/shell_params.py b/dev/breeze/src/airflow_breeze/params/shell_params.py
index 4905fb200136f..3141afe82a017 100644
--- a/dev/breeze/src/airflow_breeze/params/shell_params.py
+++ b/dev/breeze/src/airflow_breeze/params/shell_params.py
@@ -229,6 +229,7 @@ class ShellParams:
restart: bool = False
run_db_tests_only: bool = False
run_tests: bool = False
+ sdk: tuple[str, ...] = ()
skip_assets_compilation: bool = False
skip_db_tests: bool = False
skip_environment_initialization: bool = False
@@ -663,6 +664,8 @@ def env_variables_for_docker_commands(self) -> dict[str, str]:
_set_var(_env, "GITHUB_TOKEN", self.github_token)
if "go" in self.worker_type:
_set_var(_env, "GO_WORKER", True)
+ if "java" in self.sdk:
+ _set_var(_env, "JAVA_SDK", True)
_set_var(_env, "HOST_GROUP_ID", self.host_group_id)
_set_var(_env, "HOST_OS", self.host_os)
_set_var(_env, "HOST_USER_ID", self.host_user_id)
diff --git a/dev/breeze/src/airflow_breeze/utils/docker_command_utils.py b/dev/breeze/src/airflow_breeze/utils/docker_command_utils.py
index 2f376d98e0158..479a41944812f 100644
--- a/dev/breeze/src/airflow_breeze/utils/docker_command_utils.py
+++ b/dev/breeze/src/airflow_breeze/utils/docker_command_utils.py
@@ -101,6 +101,7 @@
("docs", "/opt/airflow/docs"),
("generated", "/opt/airflow/generated"),
("go-sdk", "/opt/airflow/go-sdk"),
+ ("java-sdk", "/opt/airflow/java-sdk"),
("helm-tests", "/opt/airflow/helm-tests"),
("kubernetes-tests", "/opt/airflow/kubernetes-tests"),
("logs", "/root/airflow/logs"),
@@ -111,6 +112,7 @@
("scripts", "/opt/airflow/scripts"),
("uv.lock", "/opt/airflow/uv.lock"),
("scripts/docker/entrypoint_ci.sh", "/entrypoint"),
+ ("sdk", "/opt/airflow/sdk"),
("shared", "/opt/airflow/shared"),
("task-sdk", "/opt/airflow/task-sdk"),
]
diff --git a/dev/registry/extract_metadata.py b/dev/registry/extract_metadata.py
index 463a9c408082b..5d9f635f74ea3 100644
--- a/dev/registry/extract_metadata.py
+++ b/dev/registry/extract_metadata.py
@@ -46,7 +46,7 @@
try:
import tomllib # Python 3.11+ stdlib
except ModuleNotFoundError: # pragma: no cover -- Python 3.10 fallback
- import tomli as tomllib
+ import tomli as tomllib # type: ignore[no-redef]
import yaml
from registry_contract_models import validate_providers_catalog
diff --git a/dev/registry/extract_versions.py b/dev/registry/extract_versions.py
index d9dc4e166dcf1..2908b22b32e6a 100644
--- a/dev/registry/extract_versions.py
+++ b/dev/registry/extract_versions.py
@@ -49,7 +49,7 @@
try:
import tomllib # Python 3.11+ stdlib
except ModuleNotFoundError: # pragma: no cover -- Python 3.10 fallback
- import tomli as tomllib
+ import tomli as tomllib # type: ignore[no-redef]
from registry_contract_models import validate_provider_version_metadata
try:
diff --git a/devel-common/src/docs/provider_conf.py b/devel-common/src/docs/provider_conf.py
index 6bc9da15f5f61..b730e8f20a417 100644
--- a/devel-common/src/docs/provider_conf.py
+++ b/devel-common/src/docs/provider_conf.py
@@ -151,7 +151,7 @@
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
-empty_subpackages = ["apache", "atlassian", "common", "cncf", "dbt", "microsoft"]
+empty_subpackages = ["apache", "atlassian", "common", "cncf", "dbt", "microsoft", "sdk"]
exclude_patterns = [
"operators/_partials",
"_api/airflow/index.rst",
diff --git a/devel-common/src/tests_common/pytest_plugin.py b/devel-common/src/tests_common/pytest_plugin.py
index 98a871ebb12d2..ed3547ccbfa84 100644
--- a/devel-common/src/tests_common/pytest_plugin.py
+++ b/devel-common/src/tests_common/pytest_plugin.py
@@ -2510,7 +2510,6 @@ def execute(self, context):
from uuid6 import uuid7
from airflow.sdk import DAG
- from airflow.sdk.api.datamodels._generated import TaskInstance
from airflow.sdk.execution_time.comms import BundleInfo, StartupDetails
from airflow.timetables.base import TimeRestriction
@@ -2538,6 +2537,15 @@ def _create_task_instance(
should_retry: bool | None = None,
max_tries: int | None = None,
) -> RuntimeTaskInstance:
+ from tests_common.test_utils.version_compat import AIRFLOW_V_3_3_PLUS
+
+ if AIRFLOW_V_3_3_PLUS:
+ from airflow.sdk.execution_time.workloads.task import TaskInstanceDTO
+ else:
+ from airflow.sdk.api.datamodels._generated import ( # type: ignore[no-redef,assignment]
+ TaskInstance as TaskInstanceDTO,
+ )
+
from airflow.sdk.api.datamodels._generated import DagRun, DagRunState, TIRunContext
from airflow.utils.types import DagRunType
@@ -2615,14 +2623,17 @@ def _create_task_instance(
}
startup_details = StartupDetails(
- ti=TaskInstance(
+ ti=TaskInstanceDTO(
id=ti_id,
task_id=task.task_id,
dag_id=dag_id,
run_id=run_id,
try_number=try_number,
- map_index=map_index,
+ map_index=map_index, # type: ignore[arg-type]
dag_version_id=uuid7(),
+ pool_slots=1,
+ queue="default",
+ priority_weight=1,
),
dag_rel_path="",
bundle_info=BundleInfo(name="anything", version="any"),
diff --git a/java-sdk/.editorconfig b/java-sdk/.editorconfig
new file mode 100644
index 0000000000000..37bdc0ac6ea59
--- /dev/null
+++ b/java-sdk/.editorconfig
@@ -0,0 +1,13 @@
+root = true
+
+[*]
+end_of_line = lf
+insert_final_newline = true
+indent_style = space
+indent_size = 4
+
+[*.java]
+indent_size = 2
+
+[*.kt]
+indent_size = 2
diff --git a/java-sdk/.gitattributes b/java-sdk/.gitattributes
new file mode 100644
index 0000000000000..a87d264c425cf
--- /dev/null
+++ b/java-sdk/.gitattributes
@@ -0,0 +1,11 @@
+#
+# https://help.github.com/articles/dealing-with-line-endings/
+#
+# Linux start script should use lf
+/gradlew text eol=lf
+
+# These are Windows script files and should use crlf
+*.bat text eol=crlf
+
+# Binary files should be left untouched
+*.jar binary
diff --git a/java-sdk/.gitignore b/java-sdk/.gitignore
new file mode 100644
index 0000000000000..bf1f44332ebc8
--- /dev/null
+++ b/java-sdk/.gitignore
@@ -0,0 +1,56 @@
+.gradle
+build/
+!gradle/wrapper/gradle-wrapper.jar
+!**/src/main/**/build/
+!**/src/test/**/build/
+.kotlin
+
+### IntelliJ IDEA ###
+.idea/modules.xml
+.idea/jarRepositories.xml
+.idea/compiler.xml
+.idea/libraries/
+*.iws
+*.iml
+*.ipr
+out/
+!**/src/main/**/out/
+!**/src/test/**/out/
+
+### Eclipse ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+bin/
+!**/src/main/**/bin/
+!**/src/test/**/bin/
+
+### NetBeans ###
+/nbproject/private/
+/nbbuild/
+/dist/
+/nbdist/
+/.nb-gradle/
+
+### VS Code ###
+.vscode/
+
+### Mac OS ###
+.DS_Store
+# Ignore Gradle build output directory
+build
+
+### Artifacts of airflow standalone command ###
+airflow.cfg
+airflow.db
+simple_auth_manager_passwords.json.generated
+logs/dag_id=*
+logs/dag_processor
+
+### Compatibility Test Results ###
+validation/serialization/serialized_java.json
+validation/serialization/serialized_python.json
diff --git a/java-sdk/.gradle/8.14/checksums/checksums.lock b/java-sdk/.gradle/8.14/checksums/checksums.lock
new file mode 100644
index 0000000000000..38a166fd6fb05
Binary files /dev/null and b/java-sdk/.gradle/8.14/checksums/checksums.lock differ
diff --git a/java-sdk/.gradle/8.14/executionHistory/executionHistory.bin b/java-sdk/.gradle/8.14/executionHistory/executionHistory.bin
new file mode 100644
index 0000000000000..cf75a404b59c2
Binary files /dev/null and b/java-sdk/.gradle/8.14/executionHistory/executionHistory.bin differ
diff --git a/java-sdk/.gradle/8.14/executionHistory/executionHistory.lock b/java-sdk/.gradle/8.14/executionHistory/executionHistory.lock
new file mode 100644
index 0000000000000..341393d4aa027
Binary files /dev/null and b/java-sdk/.gradle/8.14/executionHistory/executionHistory.lock differ
diff --git a/java-sdk/.gradle/8.14/fileChanges/last-build.bin b/java-sdk/.gradle/8.14/fileChanges/last-build.bin
new file mode 100644
index 0000000000000..f76dd238ade08
Binary files /dev/null and b/java-sdk/.gradle/8.14/fileChanges/last-build.bin differ
diff --git a/java-sdk/.gradle/8.14/fileHashes/fileHashes.bin b/java-sdk/.gradle/8.14/fileHashes/fileHashes.bin
new file mode 100644
index 0000000000000..b256c6c11e6dc
Binary files /dev/null and b/java-sdk/.gradle/8.14/fileHashes/fileHashes.bin differ
diff --git a/java-sdk/.gradle/8.14/fileHashes/fileHashes.lock b/java-sdk/.gradle/8.14/fileHashes/fileHashes.lock
new file mode 100644
index 0000000000000..e1acb7f073231
Binary files /dev/null and b/java-sdk/.gradle/8.14/fileHashes/fileHashes.lock differ
diff --git a/java-sdk/.gradle/8.14/fileHashes/resourceHashesCache.bin b/java-sdk/.gradle/8.14/fileHashes/resourceHashesCache.bin
new file mode 100644
index 0000000000000..cce253cf4591a
Binary files /dev/null and b/java-sdk/.gradle/8.14/fileHashes/resourceHashesCache.bin differ
diff --git a/java-sdk/.gradle/8.14/gc.properties b/java-sdk/.gradle/8.14/gc.properties
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/java-sdk/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/java-sdk/.gradle/buildOutputCleanup/buildOutputCleanup.lock
new file mode 100644
index 0000000000000..23935d54a2ecb
Binary files /dev/null and b/java-sdk/.gradle/buildOutputCleanup/buildOutputCleanup.lock differ
diff --git a/java-sdk/.gradle/buildOutputCleanup/cache.properties b/java-sdk/.gradle/buildOutputCleanup/cache.properties
new file mode 100644
index 0000000000000..3c7ea3ecf4266
--- /dev/null
+++ b/java-sdk/.gradle/buildOutputCleanup/cache.properties
@@ -0,0 +1,2 @@
+#Fri Apr 17 20:19:04 CST 2026
+gradle.version=8.14
diff --git a/java-sdk/.gradle/buildOutputCleanup/outputFiles.bin b/java-sdk/.gradle/buildOutputCleanup/outputFiles.bin
new file mode 100644
index 0000000000000..7eda1d3d7dac0
Binary files /dev/null and b/java-sdk/.gradle/buildOutputCleanup/outputFiles.bin differ
diff --git a/java-sdk/.gradle/configuration-cache/16m28tq49dyxa8sxlh9trlhos/candidates.bin b/java-sdk/.gradle/configuration-cache/16m28tq49dyxa8sxlh9trlhos/candidates.bin
new file mode 100644
index 0000000000000..a25f3fe98a050
--- /dev/null
+++ b/java-sdk/.gradle/configuration-cache/16m28tq49dyxa8sxlh9trlhos/candidates.bin
@@ -0,0 +1 @@
+8bd1a6aa-5b1c-42ac-b3f1-4cf982bf99a³
\ No newline at end of file
diff --git a/java-sdk/.gradle/configuration-cache/709055z91n7x7acrnofh9dopi/candidates.bin b/java-sdk/.gradle/configuration-cache/709055z91n7x7acrnofh9dopi/candidates.bin
new file mode 100644
index 0000000000000..6109e72da1bce
--- /dev/null
+++ b/java-sdk/.gradle/configuration-cache/709055z91n7x7acrnofh9dopi/candidates.bin
@@ -0,0 +1 @@
+b4eed3f2-24f0-41ec-a33e-3015b2d7283µ
\ No newline at end of file
diff --git a/java-sdk/.gradle/configuration-cache/72bzbc1d4mxr7ot5a2is4jphr/candidates.bin b/java-sdk/.gradle/configuration-cache/72bzbc1d4mxr7ot5a2is4jphr/candidates.bin
new file mode 100644
index 0000000000000..607238bea4191
--- /dev/null
+++ b/java-sdk/.gradle/configuration-cache/72bzbc1d4mxr7ot5a2is4jphr/candidates.bin
@@ -0,0 +1 @@
+ec822442-c7b6-4940-8dcd-f1d4ce58c10¹
\ No newline at end of file
diff --git a/java-sdk/.gradle/configuration-cache/8bd1a6aa-5b1c-42ac-b3f1-4cf982bf99a3/.globals.work.bin b/java-sdk/.gradle/configuration-cache/8bd1a6aa-5b1c-42ac-b3f1-4cf982bf99a3/.globals.work.bin
new file mode 100644
index 0000000000000..9963720f3fb86
--- /dev/null
+++ b/java-sdk/.gradle/configuration-cache/8bd1a6aa-5b1c-42ac-b3f1-4cf982bf99a3/.globals.work.bin
@@ -0,0 +1,3 @@
+‡ÜJ°Ûê4–½ Ýæ˜.�°‰ñ!ê‰ÎFžÏ$§$ù™•?ô]½åµÖXîdÂ“Ž²Ïÿœ¾þŽïhÿJì¡ëʐ˳QÁ&ýß%ëlúíz`V
+í2æî߆æíèÂW{aúQSºө1™ ï$J,¸” ̋œq±V£`DÃÑx&Ï"є¢l”ªÒ\D3qßÖp�ÖÊËUJœ1y—â&�›4!-xõ¹‹˛‹{¬�²K;¹�~¶·%}¼_æWuN¤Ɩžñ˜QtÆìX”ó #sVY›-8K+è¡îdøüüt¤ýåªôÕ. ʕêgÞûÁØçêºÏßñåñ8š¾M© uŒ5Ñ
+c�îËöƒ0$íÂ(@´¯ÕäÙ%4ý©I™š
zû¢[
jå=^f‚Y¦Ã~݆É<½ó•Ü�žï´ U’H7–º¿bѕM¯<‚>z‘&™â)PþM¾ylZÒU—2üZ¨.‘ðĜ¾ÌÞ'–Cî A7XxI¡`Š;{#ata¸#Ãî7œ“¸[VëH%b�ñâ:ÃôXºlT™ål&zN±"�=® ÃÖ;Œ•x7§‘—ab«H€‰N0+TÒħò8ïûðbúћ�f*Ý]D1Ú°û_8ÒY®=òíQ@0O™
+
+# Airflow Java SDK
+
+A **JVM** SDK for Apache Airflow. You can use any JVM-compatible language to write
+workflow bundles, and have Airflow consume the result.
+
+The SDK and execution-time logic is implemented in Kotlin.
+An example is bundled showing how the SDK can be used in Java.
+
+## Building
+
+```bash
+./gradlew build
+```
+
+## Technical Details
+
+The Java program is launched as a subprocess by the Airflow worker and communicates
+through TCP sockets. The Java program accepts flags `--comm` and `--logs` from the
+command line.
+
+The Java program "parses" DAGs on launch, and then connects to the specified TCP servers.
+The rest is similar to the standard Airflow:
+
+* DAG-parsing:
+ 1. On connection, the parent immediately sends a DagParsingRequest through the socket.
+ 2. The Java program sends back a DagParsingResult to the parent.
+ 3. The Java program exits.
+* Execution:
+ 1. On connection, the parent immediately sends a StartupDetails through the socket.
+ 2. The Java program uses the information to find the relevant task to execute.
+ 3. The task is run.
+ 4. The Java program tells the parent to update the task's terminal state.
+ 5. The Java program exits.
+
+Communication uses the same formats as the Python-based processes.
+
+## Serialization Validation
+
+Workflow:
+
+```bash
+# 1. Generate Java output (runs as part of normal test suite)
+# More specifically, the test `sdk/src/test/kotlin/org/apache/airflow/sdk/execution/SerializationCompatibilityTest.kt` generates the output file `validation/serialization/serialized_java.json`.
+./gradlew sdk:test
+
+# 2. Generate Python output (requires Airflow env)
+uv run validation/serialization/serialize_python.py \
+ validation/serialization/test_dags.yaml \
+ validation/serialization/serialized_python.json
+
+# 3. Compare
+uv run validation/serialization/compare.py \
+ validation/serialization/serialized_python.json \
+ validation/serialization/serialized_java.json
+```
diff --git a/java-sdk/adr/0001-java-sdk-airflow-integration.md b/java-sdk/adr/0001-java-sdk-airflow-integration.md
new file mode 100644
index 0000000000000..1609e99a413e2
--- /dev/null
+++ b/java-sdk/adr/0001-java-sdk-airflow-integration.md
@@ -0,0 +1,343 @@
+
+
+# ADR-0001: Java SDK Airflow Integration
+
+## Status
+
+Accepted
+
+## Context
+
+Airflow's current execution model is Python-only: DAGs are Python files, tasks are Python callables, and the task runner forks a Python process. To support DAGs and tasks authored in other languages (starting with Java), we need an architecture that:
+
+- Allows entire DAGs to be written in a non-Python language (pure Java DAG).
+- Allows non-Python tasks to coexist with Python tasks in the same DAG (`@task.stub`).
+- Reuses the existing task-runner two-layer design (task-runner process + forked child process) so Airflow extensions (XCom backends, connections, variables) stay in Python.
+- Is extensible to other languages (Go, Rust, etc.) without per-language changes to Airflow Core.
+
+The existing task runner already uses a two-layer design. When an executor wants to run a task, it starts a task-runner process that talks to Airflow Core through the Execution API, and forks another process that talks to the task-runner through TCP to run the actual task code. All the Airflow extensions simply go into the task-runner process, keeping them in Python.
+
+The only thing missing is a way for the task-runner process to run tasks in another language.
+
+## Decision
+
+### Writing a Non-Python Task
+
+There is one way to write a non-Python task: implement the language SDK's task interface. For Java, this is the `Task` interface with a single `execute(Client client)` method. The `Client` provides access to Airflow services (connections, variables, XCom).
+
+### Two Ways to Integrate Non-Python Tasks into a DAG
+
+We provide two approaches for integrating non-Python tasks into a DAG:
+
+**a) Pure Java DAG** — define the entire DAG in Java, with no Python file at all.
+The Java SDK provides `DagBundle`, `Dag`, and `Task` interfaces:
+
+```java
+public class JavaExample implements DagBundle {
+
+ public static class Extract implements Task {
+ public void execute(Client client) throws Exception {
+ var connection = client.getConnection("test_http");
+ client.setXCom(new Date().getTime());
+ }
+ }
+
+ public static class Transform implements Task {
+ public void execute(Client client) {
+ var extract_xcom = client.getXCom("extract");
+ client.setXCom(new Date().getTime());
+ }
+ }
+
+ @Override
+ public List getDags() {
+ var dag = new Dag("java_example", null, "@daily");
+ dag.addTask("extract", Extract.class, List.of());
+ dag.addTask("transform", Transform.class, List.of("extract"));
+ return List.of(dag);
+ }
+}
+```
+
+**b) `@task.stub` in a Python DAG** — for mixed-language pipelines where Python and
+Java tasks coexist in the same DAG. The `@task.stub` syntax is already supported for
+the Go SDK; the same pattern applies to Java:
+
+```python
+@task()
+def python_task_1(ti):
+ ti.xcom_push(value="from-python", key="return_value")
+
+
+@task.stub(queue="java")
+def extract(): ...
+
+
+@task.stub(queue="java")
+def transform(): ...
+
+
+@dag(dag_id="java_example")
+def simple_dag():
+ python_task_1() >> extract() >> transform()
+```
+
+Both approaches are supported in parallel. A pure Java DAG needs no Python at all for authoring. A `@task.stub` DAG requires a Python file but lets you mix Python operators and non-Python tasks in a single pipeline.
+
+> **Note:** The current `DagBundle` interface used in pure Java DAGs is subject to review before the SDK reaches 1.0. Subclassing `Dag` directly may be a more natural fit and is being considered for post-OSS-integration.
+
+### The Coordinator Layer
+
+We introduce a **Coordinator** layer. When a DAG bundle is loaded, it not only tells Airflow how to find the DAGs (and tasks in them), but also how to *run* each task. Current Python tasks use a Python code path that runs them by forking. A new **Java Coordinator** instructs the task runner how to run tasks in JAR files.
+
+The base interface (`BaseCoordinator`) lives in `airflow.sdk.execution_time` and is selected automatically via `ProvidersManagerTaskRuntime`. The Java Coordinator lives in a provider under the `airflow.providers.sdk.java` namespace, and new language coordinators follow the same pattern.
+
+### Architecture Overview
+
+```
+ Airflow Backend Language Runtime Subprocess (Java in this example)
+ ─────────────── ──────────────────────────────────────────────────
+
+ ┌──────────────────────────────┐
+ │ DAG File (Python or JAR) │
+ │ │
+ │ @task.stub(queue="java") │
+ │ def my_java_task(): │
+ │ ... │
+ └──────────────┬───────────────┘
+ │
+ ┌──────────────▼───────────────┐ ┌──────────────────────────────┐
+ │ DAG File Processor │ │ Runtime Subprocess (Java) │
+ │ │ can_handle_dag │ │
+ │ For each file in bundle: │ _file() == True │ dag_parsing_cmd() │
+ │ ┌ coordinator handles it? ──┼───────────────────►│ │
+ │ │ Yes ──► delegate parse │ │ Java SDK parses JAR, builds │
+ │ │ No ──► Python path │ SDK Serialized │ SDK-compatible Serialized │
+ │ │ │◄─── DAG JSON ──────┤ DAG JSON (sdk, tasks, etc.) │
+ │ └ │ │ │
+ └──────────────┬───────────────┘ └──────────────────────────────┘
+ │
+ ┌──────────────▼───────────────┐
+ │ Metadata DB │
+ │ │
+ │ serialized_dag: { │ Stored as-is from the language runtime's
+ │ "relative_fileloc": │ SDK Serialized DAG JSON
+ │ "path/to/example.jar" │
+ │ } │
+ │ task_instance.queue │
+ └──────────────┬───────────────┘
+ │
+ ┌──────────────▼───────────────┐
+ │ Scheduler │
+ │ │
+ │ Reads queue from TI │
+ │ ──► ExecuteTask workload │
+ │ (includes queue) │
+ └──────────────┬───────────────┘
+ │
+ ┌──────────────▼───────────────┐ ┌──────────────────────────────┐
+ │ Execution API │ │ Runtime Subprocess (Java) │
+ │ │ │ │
+ │ TI.queue ──► Startup │ │ task_execution_cmd() │
+ │ Details │ │ Executes task in JVM │
+ └──────────────┬───────────────┘ │ │
+ │ └──────────────▲───────────────┘
+ ┌──────────────▼───────────────┐ │
+ │ Task Runner │ │
+ │ │ │
+ │ QueueToCoordinatorMapper │ │
+ │ maps queue via `[sdk] │ │
+ │ queue_to_sdk` config ───────┼───────────────────────────────────┘
+ │ to matching coordinator │
+ └──────────────────────────────┘
+```
+
+### The `BaseCoordinator` Interface
+
+This is the central abstraction that language providers implement. It lives in the Task SDK (`task-sdk/src/airflow/sdk/execution_time/coordinator.py`) and handles both DAG parsing and task execution for a specific language runtime.
+
+```python
+class BaseCoordinator:
+ """
+ Base coordinator for runtime-specific DAG file processing and task execution.
+
+ Providers register subclasses in their ``provider.yaml`` under
+ ``coordinators``. Both ProvidersManager (airflow-core) and
+ ProvidersManagerTaskRuntime (task-sdk) discover coordinators through
+ this extension point.
+
+ Subclasses represent a specific language runtime (Java, Go, etc.) and
+ implement three methods. The base class owns the full bridge lifecycle:
+ TCP servers, subprocess management, selector-based I/O loop, and cleanup.
+ """
+
+ sdk: str # e.g. "java", "go" — matches sdk field on operator/TI
+
+ # Discovery (called by DAG File Processor)
+
+ @classmethod
+ def can_handle_dag_file(cls, bundle_name: str, path: str | os.PathLike) -> bool:
+ """Return True if this coordinator should parse the file at *path*."""
+ ...
+
+ @classmethod
+ def get_code_from_file(cls, fileloc: str) -> str:
+ """Return the actual DAG code (the content of JavaExample.java in this case"""
+ ...
+
+ # DAG Parsing (called in forked DagFileProcessor child process)
+
+ @classmethod
+ def dag_parsing_cmd(
+ cls,
+ *,
+ dag_file_path: str, # Absolute path to DAG file
+ bundle_name: str, # Name of the DAG bundle
+ bundle_path: str, # Root path of the bundle
+ comm_addr: str, # host:port for msgpack comm channel
+ logs_addr: str, # host:port for structured JSON log channel
+ ) -> list[str]:
+ """Return the subprocess command for DAG file parsing."""
+ ...
+
+ # Task Execution (called in forked worker child process)
+
+ @classmethod
+ def task_execution_cmd(
+ cls,
+ *,
+ what: TaskInstance,
+ dag_rel_path: str | os.PathLike, # Relative path to DAG file within bundle
+ bundle_info: BundleInfo,
+ comm_addr: str,
+ logs_addr: str,
+ ) -> list[str]:
+ """Return the subprocess command for task execution."""
+ ...
+
+ # Lifecycle (owned by base class, not overridden)
+
+ @classmethod
+ def run_dag_parsing(cls, *, path, bundle_name, bundle_path) -> None: ...
+
+ @classmethod
+ def run_task_execution(cls, *, what, dag_rel_path, bundle_info, startup_details) -> None: ...
+```
+
+### Provider Registration
+
+Language providers register their coordinators in `provider.yaml`:
+
+```yaml
+# providers/sdk/java/provider.yaml
+process-coordinators:
+ - airflow.providers.sdk.java.coordinator.JavaCoordinator
+```
+
+### Example: `JavaCoordinator`
+
+```python
+class JavaCoordinator(BaseCoordinator):
+ sdk = "java"
+
+ @classmethod
+ def can_handle_dag_file(cls, bundle_name, path):
+ """True when path is a JAR with a Main-Class manifest entry."""
+ ...
+
+ @classmethod
+ def dag_parsing_cmd(cls, *, dag_file_path, bundle_name, bundle_path, comm_addr, logs_addr):
+ main_class = find_main_class(Path(dag_file_path))
+ return [
+ "java",
+ "-classpath",
+ f"{bundle_path}/*",
+ main_class,
+ f"--comm={comm_addr}",
+ f"--logs={logs_addr}",
+ ]
+
+ @classmethod
+ def task_execution_cmd(cls, *, what, dag_rel_path, bundle_info, comm_addr, logs_addr):
+ jar_path = Path(dag_rel_path)
+ main_class = find_main_class(jar_path)
+ return [
+ "java",
+ "-classpath",
+ f"{jar_path.parent}/*",
+ main_class,
+ f"--comm={comm_addr}",
+ f"--logs={logs_addr}",
+ ]
+```
+
+### Integration Points — Required Changes
+
+**1. Decorator — DAG Author Interface**
+
+DAG authors declare a non-Python task using `@task.stub` and specify a queue:
+
+```python
+@task.stub(queue="java")
+def my_java_task(): ...
+```
+
+**2. Serialization — Each Language SDK Produces SDK-Compatible Serialized DAG JSON**
+
+Serialization is the language runtime's responsibility, not Airflow Core's. Each language SDK implements its own serializer that understands the language-specific DAG and task structure and produces a Task SDK-compatible Serialized DAG JSON — the same schema that the Python SDK's `SerializedDAG` produces.
+
+The language runtime subprocess returns this JSON to the DAG File Processor through the msgpack comm channel. The DAG File Processor and Airflow Core treat it identically to Python-serialized DAGs — it is stored as-is in the metadata DB.
+
+We have already added compatibility validation between the Python SDK and Java SDK serialized DAG JSON formats to ensure both produce structurally equivalent output.
+
+**3. Execution API — Task Queues Routed to the Worker**
+
+A new configuration is added to map each task's `queue` to a language runtime:
+
+```ini
+[sdk]
+queue_to_sdk = {"java": "java"}
+```
+
+This specifies tasks in the `java` queue should be routed to `JavaCoordinator` since it has `sdk = "java"`.
+
+## Consequences
+
+### New Interfaces
+
+| Component | New Interface | Change Type |
+|-----------|--------------|-------------|
+| `BaseCoordinator` | Abstract base defined in Task SDK | New class |
+| `coordinators` | Provider extension point in `provider.yaml` | New extension point |
+| `@task.stub` decorator | `queue: str \| None` parameter | Additive |
+| `[sdk] queue_to_sdk` | Airflow configuration | New option |
+| `_resolve_runtime_entrypoint` | Route by `queue` → `sdk` match | Behavioral |
+
+### What Becomes Easier
+
+- Adding a new language runtime requires only a `BaseCoordinator` subclass, a language SDK, and a `provider.yaml` entry — no changes to Airflow Core.
+- DAG authors can mix Python and non-Python tasks in the same pipeline.
+- The existing task-runner two-layer design is preserved, keeping all Airflow extensions in Python.
+
+### What Becomes Harder
+
+- Each language SDK must independently produce compatible serialized DAG JSON, which requires cross-language validation infrastructure.
+- The coordinator subprocess bridge adds a TCP hop and process management overhead per non-Python task.
+- Debugging non-Python tasks requires understanding the bridge layer between the task runner and the language runtime.
diff --git a/java-sdk/adr/0002-dag-parsing.md b/java-sdk/adr/0002-dag-parsing.md
new file mode 100644
index 0000000000000..235658d5460bd
--- /dev/null
+++ b/java-sdk/adr/0002-dag-parsing.md
@@ -0,0 +1,370 @@
+
+
+# ADR-0002: DAG Parsing — Language-Specific DAG File Processing
+
+## Status
+
+Accepted
+
+## Context
+
+Airflow's standard DAG file processor only understands Python files. To support DAGs defined in other languages (Java, Go, Rust, etc.), the pipeline needs an extension point where a language-specific processor can intercept the parsing request, delegate to an external runtime, and return a result in the same format the Airflow scheduler expects.
+
+This ADR details the DAG parsing side of the coordinator architecture described in [ADR-0001](0001-java-sdk-airflow-integration.md). It starts with the generic model — the abstract contracts and expected behavior that any language must implement — then walks through Java as a concrete example.
+
+## Decision
+
+### Extension Point: `BaseCoordinator`
+
+A single abstract base class — `BaseCoordinator` — handles both DAG parsing and task execution. It is registered in `provider.yaml` under `coordinators`. For DAG parsing, a subclass must implement two methods:
+
+| Method | Signature | Responsibility |
+|---|---|---|
+| `can_handle_dag_file` | `(bundle_name, path) -> bool` | Return `True` if this coordinator should handle the given file. Default returns `False`; subclasses add language-specific checks (e.g., "is this a JAR with a Main-Class?"). |
+| `dag_parsing_cmd` | `(dag_file_path, bundle_name, bundle_path, comm_addr, logs_addr) -> list[str]` | Return the full command to launch the language runtime. `comm_addr` and `logs_addr` are `host:port` strings the process must connect to. |
+
+### Registration
+
+In the provider's `provider.yaml`:
+
+```yaml
+process-coordinators:
+ - airflow.providers.sdk..coordinator.
+```
+
+A single registration covers both DAG parsing and task execution — there are no separate `dag-file-processors` or `task-coordinators` keys.
+
+### Discovery: `_resolve_processor_target()`
+
+When `DagFileProcessorProcess.start()` needs to parse a file:
+
+```
+_resolve_processor_target(path, bundle_name, bundle_path)
+ for each class_path in ProvidersManager().coordinators:
+ coordinator_cls = import_string(class_path)
+ if coordinator_cls.can_handle_dag_file(bundle_name, path):
+ return functools.partial(coordinator_cls.run_dag_parsing, path=..., bundle_name=..., bundle_path=...)
+ return None # fall back to default Python parser
+```
+
+The first coordinator whose `can_handle_dag_file()` returns `True` wins. If none match, the default Python `_parse_file_entrypoint` runs.
+
+### What the Base Class Handles Automatically
+
+The matched coordinator's `run_dag_parsing()` (a concrete method on `BaseCoordinator`) delegates to `_runtime_subprocess_entrypoint()`, which handles all the TCP/process plumbing:
+
+1. Creates two TCP servers on `127.0.0.1` with random ports (comm + logs)
+2. Creates a stderr socketpair
+3. Calls `dag_parsing_cmd()` to get the command
+4. Spawns the subprocess with `stdin=DEVNULL` (does NOT inherit fd 0)
+5. Accepts TCP connections from the subprocess
+6. Wraps fd 0 as `supervisor_comm` via `os.dup(0)`
+7. Runs `_bridge()` — a raw byte forwarder between fd 0 and the TCP comm socket
+
+### Expected E2E Flow
+
+```
+Airflow Dag-Processor
+ │
+ ▼
+DagFileProcessorProcess.start(path, bundle_name, bundle_path)
+ │
+ ├─ _resolve_processor_target()
+ │ └─ iterates process-coordinators from provider.yaml
+ │ └─ first can_handle_dag_file() == True wins
+ │
+ ▼
+WatchedSubprocess.start(target=coordinator.run_dag_parsing)
+ │
+ [fork — child process gets fd 0 as Unix domain socket to supervisor]
+ │
+ ▼ (in child)
+Coordinator.run_dag_parsing(path, bundle_name, bundle_path)
+ │
+ ▼
+BaseCoordinator._runtime_subprocess_entrypoint(DagParsingInfo)
+ │
+ ├─ 1. Create TCP comm_server + logs_server on 127.0.0.1:random
+ ├─ 2. Create stderr socketpair
+ ├─ 3. Call dag_parsing_cmd() → get launch command
+ ├─ 4. Popen(cmd, stdin=DEVNULL, stderr=child_stderr)
+ ├─ 5. Accept TCP connections from the language runtime
+ ├─ 6. supervisor_comm = socket(fileno=os.dup(0))
+ └─ 7. _bridge() — raw byte forwarding until process exits
+```
+
+### Expected Message Sequence
+
+Once the bridge is running, the Airflow supervisor and the language runtime communicate directly through the bridge (raw bytes, no re-encoding):
+
+```
+Airflow Supervisor Bridge Language Runtime
+ │ │ │
+ ├── DagFileParseRequest ──────────┼──────────────────────►│
+ │ [4-byte len][msgpack frame] │ raw byte forward │
+ │ │ │
+ │ │ ├── parse DAGs from
+ │ │ │ bundle/file
+ │ │ │
+ │◄── DagFileParsingResult ────────┼───────────────────────┤
+ │ [4-byte len][msgpack frame] │ raw byte forward │
+ │ │ │
+ │ │ └── exit(0)
+ │ │
+ │ └── drain remaining bytes (5s deadline)
+ │ close all sockets
+```
+
+### DagFileParsingResult Format
+
+The language runtime must produce a `DagFileParsingResult` that matches Python Airflow's DagSerialization format exactly. The Airflow scheduler deserializes this into its internal model — any divergence causes parsing failures.
+
+**Envelope:**
+
+```
+{
+ "type": "DagFileParsingResult",
+ "fileloc": "",
+ "serialized_dags": [
+ {
+ "data": {
+ "__version": 3,
+ "dag": { }
+ }
+ },
+ ...
+ ]
+}
+```
+
+**Serialized DAG structure** (version 3):
+
+| Field | Type | Required | Description |
+|---|---|---|---|
+| `dag_id` | string | yes | Unique identifier |
+| `fileloc` | string | yes | Source file path (can be empty) |
+| `relative_fileloc` | string | yes | Relative source path (can be empty) |
+| `timezone` | string | yes | Always `"UTC"` |
+| `timetable` | `{__type, __var}` | yes | Schedule timetable (see below) |
+| `tasks` | list | yes | Serialized task list |
+| `dag_dependencies` | list | yes | Empty list for non-Python DAGs |
+| `task_group` | map | yes | Flat root task group |
+| `edge_info` | map | yes | Empty map |
+| `params` | list | yes | DAG-level parameters |
+| `description` | string | if set | |
+| `start_date` | float (epoch) | if set | Unwrapped from `__type`/`__var` |
+| `end_date` | float (epoch) | if set | Unwrapped from `__type`/`__var` |
+| `tags` | list | if non-empty | Unwrapped from `__type`/`__var` |
+| `catchup` | bool | if `true` | |
+| `max_active_tasks` | int | if non-default | |
+| `max_active_runs` | int | if non-default | |
+
+**Timetable encoding:**
+
+| Schedule | `__type` | `__var` |
+|---|---|---|
+| `null` | `airflow.timetables.simple.NullTimetable` | `{}` |
+| `@once` | `airflow.timetables.simple.OnceTimetable` | `{}` |
+| `@continuous` | `airflow.timetables.simple.ContinuousTimetable` | `{}` |
+| cron expr | `airflow.timetables.trigger.CronTriggerTimetable` | `{expression, timezone, interval, run_immediately}` |
+
+**Task encoding:**
+
+```
+{
+ "__type": "operator",
+ "__var": {
+ "task_id": "",
+ "task_type": "",
+ "_task_module": "",
+ "downstream_task_ids": [""] // only if non-empty
+ }
+}
+```
+
+**Value type encoding** (for complex fields):
+
+| Type | Encoding |
+|---|---|
+| datetime | `{"__type": "datetime", "__var": }` |
+| timedelta | `{"__type": "timedelta", "__var": }` |
+| dict | `{"__type": "dict", "__var": {k: serialize(v), ...}}` |
+| set | `{"__type": "set", "__var": [sorted_items]}` |
+| list | `[serialize(item), ...]` (no wrapper) |
+| primitives | pass through unchanged |
+
+**Non-decorated vs decorated fields:** Some fields (like `start_date`, `end_date`, `tags`) are "non-decorated" — they are serialized with `__type`/`__var` wrapping but then unwrapped to just the `__var` value before inclusion in the DAG dict. Other fields (like `default_args`, `access_control`) are "decorated" — they keep the `__type`/`__var` wrapper. This matches Python's `serialize_to_json` behavior.
+
+### What a Language Provider Must Implement
+
+For DAG parsing, a new language provider needs:
+
+1. **A `BaseCoordinator` subclass** with:
+ - `can_handle_dag_file()` — language-specific file detection (e.g., "is this a JAR?", "is this a .go file?")
+ - `dag_parsing_cmd()` — returns the command to launch the runtime
+
+2. **A runtime process** that:
+ - Accepts `--comm=host:port` and `--logs=host:port` CLI arguments
+ - Connects to both TCP addresses
+ - Reads a `DagFileParseRequest` msgpack frame from the comm channel
+ - Parses the DAGs from the bundle
+ - Serializes the result to DagSerialization v3 format
+ - Sends back a `DagFileParsingResult` msgpack frame
+ - Exits
+
+3. **Registration** in `provider.yaml` under `process-coordinators`
+
+### Java as a Concrete Example
+
+**JavaCoordinator:**
+
+The Java provider implements all DAG-parsing contracts in a single `BaseCoordinator` subclass:
+
+```python
+# providers/sdk/java/coordinator.py
+class JavaCoordinator(BaseCoordinator):
+ sdk = "java"
+
+ @classmethod
+ def can_handle_dag_file(cls, bundle_name, path) -> bool:
+ # Returns True when path is a JAR with a Main-Class manifest entry
+ with contextlib.suppress(FileNotFoundError):
+ return find_main_class(Path(path)) is not None
+ return False
+
+ @classmethod
+ def dag_parsing_cmd(cls, *, dag_file_path, bundle_name, bundle_path, comm_addr, logs_addr):
+ main_class = find_main_class(Path(dag_file_path))
+ return [
+ "java",
+ "-classpath",
+ f"{bundle_path}/*",
+ main_class,
+ f"--comm={comm_addr}",
+ f"--logs={logs_addr}",
+ ]
+```
+
+`can_handle_dag_file()` checks that the file is a JAR with a `Main-Class` in its manifest. This ensures the coordinator only claims files it can actually handle.
+
+The classpath is `/*` — a wildcard that includes all JARs in the directory (the application JAR plus its dependencies).
+
+No separate `JavaDagFileProcessor` class is needed — `BaseCoordinator` consolidates file detection, DAG parsing, and task execution into a single extension point.
+
+**Java SDK Bundle Process:**
+
+The Java bundle process (`Server.kt`) starts, connects to both TCP servers, and enters `CoordinatorComm.startProcessing()`. When it receives a `DagFileParseRequest`:
+
+```
+CoordinatorComm.handleIncoming(frame)
+ │
+ ├── frame.body is DagFileParseRequest
+ │ file: String ← the path from the request
+ │
+ ▼
+DagParser(request.file).parse(bundle)
+ │
+ ├── Returns DagParsingResult(fileloc=file, dags=bundle.dags)
+ │ The DAGs were already loaded into the Bundle at startup
+ │ via DagBundle.getDags()
+ │
+ ▼
+sendMessage(frame.id, result)
+ │
+ ├── CoordinatorComm.encode(OutgoingFrame(id, result))
+ │ ├── detects DagParsingResult type
+ │ └── calls result.serialize() ← Serde.kt
+ │
+ ├── DagParsingResult.serialize()
+ │ ├── Wraps each DAG: {"data": {"__version": 3, "dag": dag.serialize(id)}}
+ │ ├── Dag.serialize() produces the full v3 format:
+ │ │ timetable, tasks, task_group, params, optional fields...
+ │ ├── Task.serialize() wraps as {"__type": "operator", "__var": {...}}
+ │ └── serializeValue() handles datetime/timedelta/dict/set encoding
+ │
+ ├── TaskSdkFrames.encodeRequest(id, serializedMap)
+ │ ├── Converts map to msgpack: [id, body]
+ │ └── Returns byte array
+ │
+ └── Writes [4-byte length prefix][msgpack payload] to comm channel
+
+shutDownRequested = true ← one-shot, process will exit
+```
+
+**Java SDK DagBundle Interface:**
+
+Bundle authors implement `DagBundle` to define their DAGs:
+
+```java
+public class JavaExample implements DagBundle {
+ @Override
+ public List getDags() {
+ var dag = new Dag("java_example", null, "@daily");
+ dag.addTask("extract", Extract.class, List.of());
+ dag.addTask("transform", Transform.class, List.of("extract"));
+ dag.addTask("load", Load.class, List.of("transform"));
+ return List.of(dag);
+ }
+
+ public static void main(String[] args) {
+ var example = new JavaExample();
+ var bundle = new Bundle(
+ JavaExample.class.getPackage().getImplementationVersion(),
+ example.getDags()
+ );
+ Server.create(args).serve(bundle);
+ }
+}
+```
+
+The `Dag` class provides a fluent API:
+
+- `dagId`, `description`, `schedule` (cron or preset), `startDate`, `endDate`, and all standard Airflow DAG parameters
+- `addTask(id, taskClass, dependsOn)` — registers a task and its upstream dependencies
+- Dependencies are stored as a `dependants` map (parent → set of children), serialized as `downstream_task_ids`
+
+**Java SDK Serialization Compatibility:**
+
+The serialization in `Serde.kt` is validated against Python's output:
+
+```bash
+# 1. Java generates serialized output
+./gradlew sdk:test
+# → writes validation/serialization/serialized_java.json
+
+# 2. Python generates the same DAGs
+uv run validation/serialization/serialize_python.py \
+ validation/serialization/test_dags.yaml \
+ validation/serialization/serialized_python.json
+
+# 3. Field-by-field comparison
+uv run validation/serialization/compare.py \
+ validation/serialization/serialized_python.json \
+ validation/serialization/serialized_java.json
+```
+
+Both share test cases defined in `test_dags.yaml`, ensuring the Java SDK produces byte-identical output to Python's `DagSerialization.serialize_dag()` for the same inputs.
+
+## Consequences
+
+- The DAG file processor can be extended to any language without modifying Airflow Core — only a provider with a `BaseCoordinator` subclass is needed.
+- The language runtime must produce exact DagSerialization v3 JSON, requiring cross-language validation infrastructure (e.g., `test_dags.yaml` + `compare.py`).
+- The base class absorbs all TCP/process plumbing, so language providers only implement two methods for DAG parsing.
+- The subprocess bridge adds latency and a process boundary; DAG parsing for non-Python files is inherently slower than in-process Python parsing.
diff --git a/java-sdk/adr/0003-workload-execution.md b/java-sdk/adr/0003-workload-execution.md
new file mode 100644
index 0000000000000..afa90a16e9e71
--- /dev/null
+++ b/java-sdk/adr/0003-workload-execution.md
@@ -0,0 +1,473 @@
+
+
+# ADR-0003: Workload Execution — Language-Specific Task Execution
+
+## Status
+
+Accepted
+
+## Context
+
+Airflow's standard task runner executes Python callables. To support tasks written in other languages, the pipeline needs an extension point where a language-specific coordinator can intercept the execution, delegate to an external runtime process, and bridge the Task SDK protocol so the external process can access Airflow services (connections, variables, XCom) during execution.
+
+This ADR details the task execution side of the coordinator architecture described in [ADR-0001](0001-java-sdk-airflow-integration.md). It starts with the generic model — the abstract contracts and expected behavior that any language must implement — then walks through Java as a concrete example.
+
+## Decision
+
+### Extension Point: `BaseCoordinator`
+
+The same `BaseCoordinator` base class that handles DAG parsing also handles task execution. It is registered in `provider.yaml` under `coordinators`. For task execution, a subclass must implement:
+
+| Method | Signature | Responsibility |
+|---|---|---|
+| `task_execution_cmd` | `(what, dag_rel_path, bundle_info, comm_addr, logs_addr) -> list[str]` | Return the full command to launch the language runtime for task execution. `comm_addr` and `logs_addr` are `host:port` strings the process must connect to. |
+
+The base class provides `run_task_execution()` as a concrete method that handles all TCP/process plumbing automatically (same pattern as `run_dag_parsing()` for the DAG parsing side).
+
+**Parameters passed to `run_task_execution()`:**
+
+| Parameter | Type | Description |
+|---|---|---|
+| `what` | `TaskInstance` | The task instance to execute (id, dag_id, task_id, run_id, try_number, etc.) |
+| `dag_rel_path` | `str \| PathLike` | Relative path to the DAG file / bundle within the bundle root |
+| `bundle_info` | `BundleInfo` | Bundle name and version |
+| `startup_details` | `StartupDetails` | Full startup context (task instance, DAG rel path, bundle info, run context, start date) — already consumed from fd 0 |
+
+### Registration
+
+The same `coordinators` entry in `provider.yaml` covers both DAG parsing and task execution — no separate registration needed:
+
+```yaml
+coordinators:
+ - airflow.providers.sdk..coordinator.
+```
+
+### Discovery: `_resolve_runtime_entrypoint()`
+
+When `task_runner.main()` starts, before any Python task execution:
+
+```
+task_runner.main()
+ → startup_details = get_startup_details() # reads from fd 0
+ → _resolve_runtime_entrypoint(startup_details)
+ for each class_path in ProvidersManagerTaskRuntime().process_coordinators:
+ coordinator_cls = import_string(class_path)
+ if not hasattr(coordinator_cls, "run_task_execution"):
+ continue
+ return functools.partial(coordinator_cls.run_task_execution,
+ what=..., dag_rel_path=..., bundle_info=..., startup_details=...)
+ return None # fall back to default Python execution
+
+ → if runtime_entrypoint is not None:
+ runtime_entrypoint() # language-specific execution
+ return # short-circuit — skip Python execution entirely
+```
+
+> **Note:** Currently the first coordinator with `run_task_execution` wins. `QueueToCoordinatorMapper` maps the task's `queue` to the correct coordinator via the `[sdk] queue_to_sdk` configuration.
+
+### Expected E2E Flow
+
+```
+Airflow Executor (dispatches task)
+ │
+ ▼
+WatchedSubprocess.start(target=task_runner.main)
+ │
+ [fork — child process gets fd 0 as Unix domain socket to supervisor]
+ │
+ ▼ (in child)
+task_runner.main()
+ │
+ ├─ get_startup_details() ← reads StartupDetails from fd 0
+ │
+ ├─ _resolve_runtime_entrypoint()
+ │ └─ iterates coordinators from provider.yaml
+ │ └─ first with run_task_execution wins
+ │
+ ▼
+Coordinator.run_task_execution(what, dag_rel_path, bundle_info, startup_details)
+ │
+ ▼
+BaseCoordinator._runtime_subprocess_entrypoint(TaskExecutionInfo)
+ │
+ ├─ 1. Create TCP comm_server + logs_server on 127.0.0.1:random
+ ├─ 2. Create stderr socketpair
+ ├─ 3. Call task_execution_cmd() → get launch command
+ ├─ 4. Popen(cmd, stdin=DEVNULL, stderr=child_stderr)
+ ├─ 5. Accept TCP connections from the language runtime
+ ├─ 6. _send_startup_details(runtime_comm, startup_details)
+ │ └─ re-serializes with model_dump(mode="json") to avoid
+ │ msgpack extension types non-Python decoders can't handle
+ ├─ 7. supervisor_comm = socket(fileno=os.dup(0))
+ └─ 8. _bridge() — raw byte forwarding until process exits
+```
+
+Key difference from DAG parsing: In task execution, `task_runner.main()` has already consumed `StartupDetails` from fd 0. The bridge must re-send `StartupDetails` to the language runtime over TCP before starting the byte-forwarding bridge. This is done via `_send_startup_details()`, which re-serializes using JSON mode to avoid msgpack extension types (like `Timestamp`) that non-Python decoders may not support.
+
+### Expected Message Sequence
+
+Task execution is a multi-round conversation, unlike DAG parsing's single request/response:
+
+```
+Airflow Supervisor Bridge Language Runtime
+ │ │ │
+ │ [StartupDetails sent by bridge directly] │
+ │ ├── StartupDetails ────►│
+ │ │ │
+ │ │ ├── Look up task
+ │ │ │ from bundle
+ │ │ │
+ │ │ ┌───────────────────┤
+ │ │ │ Task code runs │
+ │ │ │ and may request: │
+ │ │ │ │
+ │◄── GetConnection(conn_id) ──────┼───┤ │
+ │ │ │ │
+ ├── ConnectionResult ─────────────┼──►│ │
+ │ │ │ │
+ │◄── GetVariable(key) ────────────┼───┤ │
+ │ │ │ │
+ ├── VariableResult ───────────────┼──►│ │
+ │ │ │ │
+ │◄── GetXCom(key, dag_id, ...) ───┼───┤ │
+ │ │ │ │
+ ├── XComResult ───────────────────┼──►│ │
+ │ │ │ │
+ │◄── SetXCom(key, value, ...) ────┼───┤ │
+ │ │ │ │
+ ├── (empty response) ─────────────┼──►│ │
+ │ │ │ │
+ │ │ └───────────────────┤
+ │ │ │
+ │◄── SucceedTask / TaskState ─────┼───────────────────────┤
+ │ (terminal — no response) │ │
+ │ │ └── exit(0)
+ │ │
+ │ └── drain, close sockets
+```
+
+### Task SDK Protocol Messages
+
+The language runtime exchanges these message types with the Airflow supervisor:
+
+**Runtime → Supervisor (requests):**
+
+| Message | Fields | Purpose |
+|---|---|---|
+| `GetConnection` | `conn_id` | Fetch an Airflow connection by ID |
+| `GetVariable` | `key` | Fetch an Airflow variable by key |
+| `GetXCom` | `key`, `dag_id`, `task_id`, `run_id`, `map_index?`, `include_prior_dates?` | Fetch an XCom value |
+| `SetXCom` | `key`, `value`, `dag_id`, `task_id`, `run_id`, `map_index`, `mapped_length?` | Store an XCom value |
+| `SucceedTask` | `end_date`, `task_outlets?`, `outlet_events?` | Terminal: task succeeded |
+| `TaskState` | `state` (`"failed"`, `"removed"`, `"skipped"`), `end_date` | Terminal: task ended non-successfully |
+
+**Supervisor → Runtime (responses):**
+
+| Message | Fields | In response to |
+|---|---|---|
+| `ConnectionResult` | `conn_id`, `conn_type`, `host`, `schema`, `login`, `password`, `port`, `extra` | `GetConnection` |
+| `VariableResult` | `key`, `value` | `GetVariable` |
+| `XComResult` | `key`, `value` | `GetXCom` |
+| (empty) | | `SetXCom` |
+| `ErrorResponse` | `error`, `detail` | Any request that failed server-side |
+
+**Framing:** Every message is a length-prefixed msgpack frame. Requests are `[id, body]` (2-element array); responses are `[id, body, error]` (3-element array). The `id` field correlates request/response pairs.
+
+### Request/Response Semantics
+
+The task execution follows a synchronous request/response pattern from the runtime's perspective:
+
+1. The runtime sends a request frame (e.g., `GetVariable`) with an incrementing `id`
+2. The supervisor reads the frame, fulfills the request (e.g., calls the Execution API), and sends back a response with the same `id`
+3. The runtime blocks until it receives the response
+4. This repeats for each Airflow service call the task code makes
+5. When the task finishes, the runtime sends a terminal message (`SucceedTask` or `TaskState`) — no response is expected, and the process exits
+
+### StartupDetails
+
+The first message the runtime receives is `StartupDetails`, which provides full context for the task:
+
+| Field | Type | Description |
+|---|---|---|
+| `ti` | `TaskInstance` | id, task_id, dag_id, run_id, try_number, dag_version_id, map_index, context_carrier |
+| `dag_rel_path` | string | Relative path to the DAG file / bundle |
+| `bundle_info` | `BundleInfo` | name, version |
+| `start_date` | datetime | When this task attempt started |
+| `ti_context` | `TIRunContext` | DAG run context (logical date, data interval, etc.) |
+| `sentry_integration` | string | Sentry DSN for error reporting (optional) |
+
+### What a Language Provider Must Implement
+
+For task execution, a new language provider needs:
+
+1. **A `BaseCoordinator` subclass** with:
+ - `task_execution_cmd()` — returns the command to launch the runtime
+ - (This is the same subclass that implements `can_handle_dag_file()` and `dag_parsing_cmd()` for DAG parsing — one class covers both)
+
+2. **A runtime process** that:
+ - Accepts `--comm=host:port` and `--logs=host:port` CLI arguments
+ - Connects to both TCP addresses
+ - Reads a `StartupDetails` msgpack frame from the comm channel
+ - Looks up the task to execute from its bundle using `ti.dag_id` and `ti.task_id`
+ - Executes the task, making `GetConnection`/`GetVariable`/`GetXCom`/`SetXCom` requests as needed
+ - Sends `SucceedTask` on success or `TaskState("failed")` on failure
+ - Exits
+
+3. **A task interface** that user code implements (analogous to Python's `@task` decorator or `BaseOperator`)
+
+4. **A client API** that wraps the socket protocol behind a simple interface (get_connection, get_variable, get_xcom, set_xcom) so task authors don't deal with framing
+
+5. **Registration** in `provider.yaml` under `coordinators` (same entry as DAG parsing — no separate registration)
+
+### Java as a Concrete Example
+
+**JavaCoordinator (Python side):**
+
+The same `JavaCoordinator` that handles DAG parsing also handles task execution — no separate `JavaTaskCoordinator` class is needed:
+
+```python
+# providers/sdk/java/coordinator.py
+class JavaCoordinator(BaseCoordinator):
+ sdk = "java"
+
+ @classmethod
+ def can_handle_dag_file(cls, bundle_name, path) -> bool:
+ with contextlib.suppress(FileNotFoundError):
+ return find_main_class(Path(path)) is not None
+ return False
+
+ @classmethod
+ def dag_parsing_cmd(cls, *, dag_file_path, bundle_name, bundle_path, comm_addr, logs_addr):
+ main_class = find_main_class(Path(dag_file_path))
+ return [
+ "java",
+ "-classpath",
+ f"{bundle_path}/*",
+ main_class,
+ f"--comm={comm_addr}",
+ f"--logs={logs_addr}",
+ ]
+
+ @classmethod
+ def task_execution_cmd(cls, *, what, dag_rel_path, bundle_info, comm_addr, logs_addr):
+ jar_path = Path(dag_rel_path)
+ main_class = find_main_class(jar_path)
+ return [
+ "java",
+ "-classpath",
+ f"{jar_path.parent}/*",
+ main_class,
+ f"--comm={comm_addr}",
+ f"--logs={logs_addr}",
+ ]
+```
+
+One class, one `provider.yaml` entry, covers both DAG parsing and task execution.
+
+**Java SDK Task Interface:**
+
+User task code implements a single-method interface:
+
+```java
+// sdk: org.apache.airflow.sdk.Task
+public interface Task {
+ void execute(Client client) throws Exception;
+}
+```
+
+The `Client` provides access to Airflow services:
+
+```java
+// sdk: org.apache.airflow.sdk.Client
+public class Client {
+ // Access task metadata
+ public StartupDetails getDetails();
+
+ // Airflow services
+ public Connection getConnection(String id);
+ public Object getVariable(String key);
+ public Object getXCom(String key, String dagId, String taskId, String runId, ...);
+ public void setXCom(String key, Object value); // defaults: key="return_value", dagId/taskId/runId from current task
+}
+```
+
+**Java SDK Task Execution Flow:**
+
+When the bundle process receives `StartupDetails`:
+
+```
+CoordinatorComm.handleIncoming(frame)
+ │
+ ├── frame.body is StartupDetails
+ │ ti: TaskInstance (id, dagId, taskId, runId, tryNumber, ...)
+ │ dagRelPath, bundleInfo, startDate, tiContext
+ │
+ ▼
+TaskRunner.run(bundle, request, comm)
+ │
+ ├── Create Client(request, CoordinatorClient(comm))
+ │ CoordinatorClient wraps the comm channel behind the Client interface
+ │
+ ├── Look up task class:
+ │ bundle.dags[request.ti.dagId]?.tasks[request.ti.taskId]
+ │ └── if not found → return TaskState("removed")
+ │
+ ├── Instantiate task:
+ │ task.getDeclaredConstructor().newInstance()
+ │
+ ├── Execute:
+ │ try {
+ │ instance.execute(client) ← USER TASK CODE RUNS HERE
+ │ return SucceedTask()
+ │ } catch (Exception e) {
+ │ return TaskState("failed")
+ │ }
+ │
+ ▼
+sendMessage(frame.id, result) ← sends SucceedTask or TaskState back
+shutDownRequested = true ← one-shot, process will exit
+```
+
+**Java SDK Airflow Service Access:**
+
+When user task code calls `client.getVariable("my_key")`, the call chain is:
+
+```
+client.getVariable("my_key") // Client.kt (public SDK)
+ │
+ └── impl.getVariable("my_key") // CoordinatorClient (execution)
+ │
+ └── runBlocking { // blocks the calling thread
+ comm.communicate( // CoordinatorComm
+ GetVariable(key = "my_key")
+ )
+ }
+ │
+ ├── sendMessage(nextId++, GetVariable) // encode + write to comm socket
+ │ ├── encode: [id, {"type": "GetVariable", "key": "my_key"}]
+ │ └── write: [4-byte len][msgpack]
+ │
+ ├── processOnce(::handle) // block until response arrives
+ │ ├── read 4-byte length prefix
+ │ ├── read payload
+ │ └── decode: [id, {"type": "VariableResult", ...}, null]
+ │
+ └── return response.value // unwrap VariableResponse
+```
+
+This is fully synchronous from the task code's perspective — `getVariable()` blocks until the supervisor responds.
+
+**Java SDK Example Task Implementation:**
+
+```java
+public static class Extract implements Task {
+ public void execute(Client client) throws Exception {
+ // Read XCom from a Python task in the same DAG
+ var pythonXcom = client.getXCom("python_task_1");
+
+ // Access Airflow connections
+ var connection = client.getConnection("test_http");
+
+ // Do work...
+ Thread.sleep(6000);
+
+ // Push XCom for downstream tasks (Java or Python)
+ client.setXCom(new Date().getTime());
+ }
+}
+
+public static class Transform implements Task {
+ public void execute(Client client) {
+ // Read XCom from upstream Java task
+ var extractXcom = client.getXCom("extract");
+
+ // Access Airflow variables
+ var variable = client.getVariable("my_variable");
+
+ // Push XCom (readable by downstream Python tasks)
+ client.setXCom(new Date().getTime());
+ }
+}
+
+public static class Load implements Task {
+ public void execute(Client client) {
+ var xcom = client.getXCom("transform");
+ throw new RuntimeException("I failed");
+ // Exception → TaskRunner catches → sends TaskState("failed")
+ }
+}
+```
+
+**Java SDK Complete Bundle Entry Point:**
+
+```java
+public class JavaExample implements DagBundle {
+ @Override
+ public List getDags() {
+ var dag = new Dag("java_example", null, "@daily");
+ dag.addTask("extract", Extract.class, List.of());
+ dag.addTask("transform", Transform.class, List.of("extract"));
+ dag.addTask("load", Load.class, List.of("transform"));
+ return List.of(dag);
+ }
+
+ public static void main(String[] args) {
+ var bundle = new Bundle(
+ JavaExample.class.getPackage().getImplementationVersion(),
+ new JavaExample().getDags()
+ );
+ Server.create(args).serve(bundle); // parses --comm/--logs, connects, enters message loop
+ }
+}
+```
+
+The same `main()` entry point handles both DAG parsing and task execution — the first message received (`DagFileParseRequest` or `StartupDetails`) determines the mode.
+
+**Java SDK Java-side Supervisor (Alternative Execution Path):**
+
+The Java SDK also provides `Supervisor.kt` for execution contexts where there is no Python process (e.g., the Edge Worker). In this path, the Supervisor terminates the protocol directly instead of bridging:
+
+```
+Supervisor.run(request)
+ │
+ ├── Create TCP comm + logs servers
+ ├── Spawn Java bundle process with --comm/--logs
+ ├── Accept connections
+ ├── HTTP PATCH task → running state
+ ├── Send StartupDetails to bundle via comm socket
+ │
+ └── serveTaskSdkRequests() loop:
+ Read frame from bundle
+ ├── GetConnection → HTTP GET /connections/{id} → send response
+ ├── GetVariable → HTTP GET /variables/{key} → send response
+ ├── GetXCom → HTTP GET /xcom/... → send response
+ ├── SetXCom → HTTP POST /xcom/... → send response
+ └── SucceedTask/TaskState → HTTP PATCH terminal state → exit loop
+```
+
+The bundle process behaves identically in both paths — it is unaware of whether its comm channel leads to a Python bridge or a Java Supervisor. This is the core design invariant of the Java SDK.
+
+## Consequences
+
+- Task execution for any language reuses the same coordinator + bridge pattern as DAG parsing, keeping the extension surface small.
+- The multi-round protocol (GetConnection, GetVariable, etc.) means the language runtime has full access to Airflow services without reimplementing them — they stay in Python.
+- The synchronous request/response model is simple for language SDK authors but adds a round-trip per service call.
+- The Java-side Supervisor (`Supervisor.kt`) provides an alternative execution path for environments without Python, but requires the Java SDK to implement HTTP calls to the Execution API directly.
+- Task authors interact with a simple `Client` interface, completely abstracted from the underlying socket protocol.
diff --git a/java-sdk/adr/0004-pure-java-dags.md b/java-sdk/adr/0004-pure-java-dags.md
new file mode 100644
index 0000000000000..a3c42672c24cf
--- /dev/null
+++ b/java-sdk/adr/0004-pure-java-dags.md
@@ -0,0 +1,228 @@
+
+
+# ADR-0004: Pure Java DAGs — Build-Time Packaging and Code Visibility
+
+## Status
+
+Accepted
+
+## Context
+
+[ADR-0001](0001-java-sdk-airflow-integration.md) introduces two ways to integrate non-Python tasks: `@task.stub` (mixed Python+Java DAGs) and pure Java DAGs (entire DAG in Java via `DagBundle`). [ADR-0002](0002-dag-parsing.md) and [ADR-0003](0003-workload-execution.md) describe the coordinator infrastructure for DAG parsing and task execution respectively.
+
+This ADR focuses on the Java-SDK-specific concerns that make pure Java DAGs work end-to-end — build-time metadata generation, source code packaging for UI visibility, and JAR manifest conventions — rather than the shared coordinator infrastructure already covered in those ADRs.
+
+The central challenge is that Airflow Core expects to read DAG metadata and source code from files on disk or from the metadata DB. A JAR is an opaque binary — Airflow cannot `open()` it and read Python source. The Java SDK must bridge this gap at build time by embedding machine-readable metadata and human-readable source into the JAR itself.
+
+## Decision
+
+### JAR Manifest Conventions
+
+The JAR manifest (`META-INF/MANIFEST.MF`) carries three SDK-specific attributes that Airflow and the Java SDK use to bootstrap a bundle:
+
+| Attribute | Example Value | Purpose |
+|---|---|---|
+| `Main-Class` | `org.apache.airflow.example.JavaExample` | Standard Java attribute; the coordinator uses it to launch the JVM |
+| `Airflow-Java-SDK-Metadata` | `airflow-metadata.yaml` | Points to the embedded metadata file (dag IDs, task IDs) |
+| `Airflow-Java-SDK-Dag-Code` | `JavaExample.java` | Points to the embedded source file for Airflow UI display |
+
+These attributes are set in the Gradle build (see [Build-Time Packaging](#build-time-packaging-gradle) below). The Python-side coordinator reads `Main-Class` to construct the launch command; `BundleScanner` reads `Airflow-Java-SDK-Metadata` to discover DAG IDs without launching the JVM.
+
+### Build-Time Metadata: `airflow-metadata.yaml`
+
+At build time, the SDK runs `BundleInspector` — a build-time utility that reflectively instantiates the user's `DagBundle` class, calls `getDags()`, and writes a YAML file listing every DAG ID and its task IDs:
+
+```yaml
+dags:
+ java_example:
+ tasks:
+ - extract
+ - transform
+ - load
+```
+
+This file is embedded in the JAR root and referenced by the `Airflow-Java-SDK-Metadata` manifest attribute.
+
+**Why build-time, not runtime?** The metadata must be available before the JVM starts. `BundleScanner` reads it from the JAR to discover which DAG IDs a bundle contains — this is used for `@task.stub` routing (mapping a `dag_id` to the correct bundle's classpath) without paying JVM startup cost. For pure Java DAGs, the coordinator already knows the bundle path, but the metadata is still useful for validation and tooling.
+
+**`BundleInspector`:**
+
+```kotlin
+object BundleInspector {
+ @JvmStatic
+ fun main(args: Array) {
+ val className = args[0]
+ val outputPath = args[1]
+ val clazz = Class.forName(className)
+ val instance = clazz.getDeclaredConstructor().newInstance() as? DagBundle
+ ?: error("$className does not implement DagBundle")
+ val dags = instance.getDags()
+ File(outputPath).apply { parentFile.mkdirs() }.writeText(toYaml(dags))
+ }
+
+ internal fun toYaml(dags: List): String = buildString {
+ appendLine("dags:")
+ for (dag in dags) {
+ appendLine(" ${dag.dagId}:")
+ appendLine(" tasks:")
+ for (taskId in dag.tasks.keys) {
+ appendLine(" - $taskId")
+ }
+ }
+ }
+}
+```
+
+### Source Code Packaging for UI Visibility
+
+Airflow stores DAG source code in the `dag_code` table and displays it in the web UI. For Python DAGs this is trivial — `DagCode.write_code()` reads the `.py` file from disk. For a JAR, the raw bytecode is not human-readable.
+
+The solution: pack the original `.java` source file into the JAR at build time. The `Airflow-Java-SDK-Dag-Code` manifest attribute tells the coordinator which file to extract.
+
+On the Python side, `get_code_from_file()` on the coordinator:
+
+1. Opens the JAR as a ZIP
+2. Reads the `Airflow-Java-SDK-Dag-Code` attribute from the manifest
+3. Extracts and returns the raw `.java` source
+
+This lets Airflow's existing `DagCode` infrastructure store and display Java source code with no changes to Airflow Core.
+
+### Build-Time Packaging (Gradle)
+
+The `example/build.gradle.kts` shows the complete packaging pattern:
+
+```kotlin
+val bundleMainClass = application.mainClass.get()
+val metadataFileName = "airflow-metadata.yaml"
+val metadataOutputDir = layout.buildDirectory.dir("airflow-metadata")
+val dagCodeSourcePath = bundleMainClass.replace('.', '/') + ".java"
+val dagCodeFileName = bundleMainClass.substringAfterLast('.') + ".java"
+
+// 1. Run BundleInspector at compile time to generate metadata
+val inspectBundle = tasks.register("inspectBundle") {
+ dependsOn("classes")
+ classpath = sourceSets.main.get().runtimeClasspath
+ mainClass.set("org.apache.airflow.sdk.BundleInspector")
+ args = listOf(bundleMainClass, metadataOutputDir.get().file(metadataFileName).asFile.absolutePath)
+}
+
+// 2. Pack metadata + source into the JAR
+tasks.withType {
+ dependsOn(inspectBundle)
+ from(metadataOutputDir) // airflow-metadata.yaml
+ from("src/java/$dagCodeSourcePath") // raw .java source file
+ manifest {
+ attributes(
+ "Main-Class" to bundleMainClass,
+ "Airflow-Java-SDK-Version" to project.version,
+ "Airflow-Java-SDK-Metadata" to metadataFileName,
+ "Airflow-Java-SDK-Dag-Code" to dagCodeFileName,
+ )
+ }
+}
+```
+
+The resulting JAR contains:
+
+```
+example.jar
+├── META-INF/MANIFEST.MF (Main-Class, SDK attributes)
+├── airflow-metadata.yaml (dag IDs + task IDs)
+├── JavaExample.java (raw source for UI display)
+├── org/apache/airflow/example/
+│ ├── JavaExample.class (compiled bundle entry point)
+│ ├── JavaExample$Extract.class
+│ ├── JavaExample$Transform.class
+│ └── JavaExample$Load.class
+└── ... (SDK + dependency classes)
+```
+
+### `BundleScanner` — Runtime Bundle Discovery
+
+`BundleScanner` reads JAR manifests at runtime to discover bundles without launching the JVM. This is used by the `@task.stub` path to resolve which bundle contains a given `dag_id`.
+
+```kotlin
+data class ResolvedBundle(
+ val mainClass: String, // From Main-Class manifest attribute
+ val classpath: String, // All JARs in bundle directory, colon-separated
+)
+
+fun scanBundles(bundlesDir: Path): Map
+```
+
+It supports two directory layouts:
+
+- **Nested**: each subdirectory of `bundlesDir` is a bundle home (e.g., `bundles/my-app/lib/*.jar`)
+- **Flat**: `bundlesDir` itself contains the JARs (e.g., `bundles/*.jar`)
+
+For each JAR, it reads the `Airflow-Java-SDK-Metadata` manifest attribute, extracts the referenced YAML, parses DAG IDs, and returns a mapping from `dag_id` to `ResolvedBundle`.
+
+### The DagBundle Authoring API
+
+Bundle authors implement `DagBundle` to define their DAGs:
+
+```java
+public class JavaExample implements DagBundle {
+
+ public static class Extract implements Task {
+ public void execute(Client client) throws Exception {
+ var connection = client.getConnection("test_http");
+ client.setXCom(new Date().getTime());
+ }
+ }
+
+ public static class Transform implements Task {
+ public void execute(Client client) {
+ var extract_xcom = client.getXCom("extract");
+ client.setXCom(new Date().getTime());
+ }
+ }
+
+ @Override
+ public List getDags() {
+ var dag = new Dag("java_example", null, "@daily");
+ dag.addTask("extract", Extract.class, List.of());
+ dag.addTask("transform", Transform.class, List.of("extract"));
+ return List.of(dag);
+ }
+
+ public static void main(String[] args) {
+ var example = new JavaExample();
+ var bundle = new Bundle(
+ JavaExample.class.getPackage().getImplementationVersion(),
+ example.getDags()
+ );
+ Server.create(args).serve(bundle);
+ }
+}
+```
+
+The `main()` method is the JVM entry point that the coordinator launches. It wires the `DagBundle` to the SDK's TCP communication layer (`Server` → `CoordinatorComm`), which handles DAG parsing requests and task execution commands as described in [ADR-0002](0002-dag-parsing.md) and [ADR-0003](0003-workload-execution.md).
+
+> **Note:** The current `DagBundle` interface is subject to review before the SDK reaches 1.0. Subclassing `Dag` directly may be a more natural fit and is being considered for post-OSS-integration.
+
+## Consequences
+
+- JAR bundles are self-contained: metadata, source, and compiled code are all in one artifact, simplifying deployment (copy one directory of JARs).
+- Build-time metadata generation means DAG IDs can be discovered without JVM startup — important for `BundleScanner` and tooling.
+- Source code packaging enables Airflow UI display with no changes to Airflow Core's `DagCode` infrastructure.
+- The manifest convention (`Airflow-Java-SDK-*` attributes) is extensible — future attributes can carry additional metadata without breaking existing tooling.
+- The build-time `BundleInspector` step adds a compile-time dependency on the SDK and requires the `DagBundle` class to be instantiable without side effects (no I/O, no connections in the constructor).
+- Bundle authors must follow the Gradle packaging pattern (or replicate it in Maven/other build tools) — this is SDK-specific boilerplate that doesn't exist for Python DAGs.
diff --git a/java-sdk/build.gradle.kts b/java-sdk/build.gradle.kts
new file mode 100644
index 0000000000000..a9fb8d993533b
--- /dev/null
+++ b/java-sdk/build.gradle.kts
@@ -0,0 +1,33 @@
+import com.diffplug.gradle.spotless.SpotlessExtension
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+
+plugins {
+ kotlin("jvm") version "2.3.0"
+ id("com.diffplug.spotless") version "7.2.1" // Last version supporting JDK 11.
+ id("org.jlleitschuh.gradle.ktlint") version "14.0.1"
+}
+
+allprojects {
+ apply(plugin = "com.diffplug.spotless")
+ apply(plugin = "org.jetbrains.kotlin.jvm")
+ apply(plugin = "org.jlleitschuh.gradle.ktlint")
+
+ repositories { mavenCentral() }
+
+ java {
+ toolchain {
+ languageVersion.set(JavaLanguageVersion.of(11))
+ }
+ sourceCompatibility = JavaVersion.VERSION_11
+ }
+ kotlin { compilerOptions { jvmTarget = JvmTarget.JVM_11 } }
+
+ configure {
+ java {
+ target("**/*.java")
+ googleJavaFormat().formatJavadoc(false)
+ trimTrailingWhitespace()
+ endWithNewline()
+ }
+ }
+}
diff --git a/java-sdk/dags/stub_dag.py b/java-sdk/dags/stub_dag.py
new file mode 100644
index 0000000000000..5bc41d1001640
--- /dev/null
+++ b/java-sdk/dags/stub_dag.py
@@ -0,0 +1,51 @@
+# 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
+
+from airflow.sdk import dag, task
+
+
+@task()
+def python_task_1(ti):
+ print("python_task_1")
+ print("Push Python Task 'python_task_1' XCom:")
+ ti.xcom_push(value="value-pushed-from-python_task_1", key="return_value")
+
+
+@task.stub(queue="java-queue")
+def extract(): ...
+
+
+@task.stub(queue="java-queue")
+def transform(): ...
+
+
+@task()
+def python_task_2(ti):
+ print("python_task_2")
+ print("Pull Java Task 'transform' XCom:")
+ print(ti.xcom_pull(task_ids="transform"))
+
+
+@dag(dag_id="java_example")
+def simple_dag():
+
+ python_task_1() >> extract() >> transform() >> python_task_2()
+
+
+simple_dag()
diff --git a/java-sdk/example/build.gradle.kts b/java-sdk/example/build.gradle.kts
new file mode 100644
index 0000000000000..e36ff5bd68199
--- /dev/null
+++ b/java-sdk/example/build.gradle.kts
@@ -0,0 +1,55 @@
+plugins {
+ application
+}
+
+dependencies {
+ implementation(project(":sdk"))
+ implementation("org.slf4j:slf4j-simple:2.0.17")
+}
+
+sourceSets {
+ main {
+ java.srcDir("src/java")
+ }
+}
+
+application {
+ mainClass = "org.apache.airflow.example.JavaExample"
+}
+
+val bundleMainClass = application.mainClass.get()
+val metadataFileName = "airflow-metadata.yaml"
+val metadataOutputDir = layout.buildDirectory.dir("airflow-metadata")
+val dagCodeSourcePath = bundleMainClass.replace('.', '/') + ".java"
+val dagCodeFileName = bundleMainClass.substringAfterLast('.') + ".java"
+
+val inspectBundle =
+ tasks.register("inspectBundle") {
+ dependsOn("classes")
+ classpath = sourceSets.main.get().runtimeClasspath
+ mainClass.set("org.apache.airflow.sdk.BundleInspector")
+ args =
+ listOf(
+ bundleMainClass,
+ metadataOutputDir
+ .get()
+ .file(metadataFileName)
+ .asFile.absolutePath,
+ )
+ }
+
+tasks.withType {
+ dependsOn(inspectBundle)
+ from(metadataOutputDir)
+ from("src/java/$dagCodeSourcePath")
+ manifest {
+ attributes(
+ "Main-Class" to bundleMainClass,
+ "Airflow-Java-SDK-Version" to project.version,
+ "Airflow-Java-SDK-Metadata" to metadataFileName,
+ "Airflow-Java-SDK-Dag-Code" to dagCodeFileName,
+ "Implementation-Title" to "Example Java bundle",
+ "Implementation-Version" to "1",
+ )
+ }
+}
diff --git a/java-sdk/example/src/java/org/apache/airflow/example/JavaExample.java b/java-sdk/example/src/java/org/apache/airflow/example/JavaExample.java
new file mode 100644
index 0000000000000..1a681dd2c9391
--- /dev/null
+++ b/java-sdk/example/src/java/org/apache/airflow/example/JavaExample.java
@@ -0,0 +1,69 @@
+package org.apache.airflow.example;
+
+import java.util.Date;
+import java.util.List;
+import org.apache.airflow.sdk.*;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class JavaExample implements DagBundle {
+ private static final Logger logger = LoggerFactory.getLogger(JavaExample.class);
+
+ public static class Extract implements Task {
+ public void execute(Client client) throws Exception {
+ logger.info("Hello from task");
+
+ var python_xcom = client.getXCom("python_task_1");
+ logger.info("Got XCom from Python Task 'python_task_1' {}", python_xcom);
+
+ var connection = client.getConnection("test_http");
+ logger.info("Got con {}", connection);
+
+ for (var i = 0; i < 3; i++) {
+ logger.info("Beep {}, next time will be {}", i, new Date());
+ Thread.sleep(2 * 1000);
+ }
+
+ client.setXCom(new Date().getTime());
+ logger.info("Goodbye from task");
+ }
+ }
+
+ public static class Transform implements Task {
+ public void execute(Client client) {
+ var extract_xcom = client.getXCom("extract");
+ logger.info("Got XCom from 'extract' {}", extract_xcom);
+
+ var variable = client.getVariable("my_variable");
+ logger.info("Got variable {}", variable);
+
+ logger.info("Push XCom to python task 2");
+ client.setXCom(new Date().getTime());
+ }
+ }
+
+ public static class Load implements Task {
+ public void execute(Client client) {
+ var xcom = client.getXCom("transform");
+ logger.info("Got XCom from 'transform' {}", xcom);
+ throw new RuntimeException("I failed");
+ }
+ }
+
+ @Override
+ public List getDags() {
+ var javaExample = new Dag("java_example", /* description= */ null, /* schedule= */ "@daily");
+ javaExample.addTask("extract", Extract.class, List.of());
+ javaExample.addTask("transform", Transform.class, List.of("extract"));
+ javaExample.addTask("load", Load.class, List.of("transform"));
+ return List.of(javaExample);
+ }
+
+ public static void main(String[] args) {
+ var example = new JavaExample();
+ var bundle =
+ new Bundle(JavaExample.class.getPackage().getImplementationVersion(), example.getDags());
+
+ Server.create(args).serve(bundle);
+ }
+}
diff --git a/java-sdk/gradle.properties b/java-sdk/gradle.properties
new file mode 100644
index 0000000000000..7ec9aa8974afa
--- /dev/null
+++ b/java-sdk/gradle.properties
@@ -0,0 +1,23 @@
+# 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.
+
+# This file was generated by the Gradle 'init' task.
+# https://docs.gradle.org/current/userguide/build_environment.html#sec:gradle_configuration_properties
+
+org.gradle.configuration-cache=true
+
+airflowExecApiVersion=2025-11-05
diff --git a/java-sdk/gradle/libs.versions.toml b/java-sdk/gradle/libs.versions.toml
new file mode 100644
index 0000000000000..a0ac505d527fe
--- /dev/null
+++ b/java-sdk/gradle/libs.versions.toml
@@ -0,0 +1,19 @@
+# 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.
+
+# This file was generated by the Gradle 'init' task.
+# https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format
diff --git a/java-sdk/gradle/wrapper/gradle-wrapper.jar b/java-sdk/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000000000..f8e1ee3125fe0
Binary files /dev/null and b/java-sdk/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/java-sdk/gradle/wrapper/gradle-wrapper.properties b/java-sdk/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000000000..bc660c8e3d572
--- /dev/null
+++ b/java-sdk/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,23 @@
+# 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.
+
+#Mon Jan 19 21:06:09 CST 2026
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/java-sdk/gradlew b/java-sdk/gradlew
new file mode 100755
index 0000000000000..adff685a0348c
--- /dev/null
+++ b/java-sdk/gradlew
@@ -0,0 +1,248 @@
+#!/bin/sh
+
+#
+# Copyright © 2015 the original authors.
+#
+# Licensed 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
+#
+# https://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.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/java-sdk/gradlew.bat b/java-sdk/gradlew.bat
new file mode 100644
index 0000000000000..c4bdd3ab8e3cc
--- /dev/null
+++ b/java-sdk/gradlew.bat
@@ -0,0 +1,93 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/java-sdk/sdk/build.gradle.kts b/java-sdk/sdk/build.gradle.kts
new file mode 100644
index 0000000000000..08d0b9c9639ef
--- /dev/null
+++ b/java-sdk/sdk/build.gradle.kts
@@ -0,0 +1,125 @@
+import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
+import org.jlleitschuh.gradle.ktlint.tasks.KtLintCheckTask
+import java.time.ZonedDateTime
+
+buildscript {
+ repositories {
+ mavenCentral()
+ }
+}
+
+val airflowExecApiVersion: String by project
+
+plugins {
+ kotlin("plugin.serialization") version "2.3.0"
+ id("org.openapi.generator") version "7.19.0"
+}
+
+val constantsDir = layout.buildDirectory.dir("generate-constants/main/src/main/kotlin")
+
+dependencies {
+ compileOnly("com.github.spotbugs:spotbugs-annotations:4.9.8")
+ compileOnly("javax.annotation:javax.annotation-api:1.3.2")
+ compileOnly("org.apache.oltu.oauth2:org.apache.oltu.oauth2.client:1.0.1")
+
+ implementation("com.fasterxml.jackson.core:jackson-annotations:2.21")
+ implementation("com.fasterxml.jackson.core:jackson-core:2.21.1")
+ implementation("com.fasterxml.jackson.core:jackson-databind:2.21.0")
+ implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.21.0")
+ implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.21.0")
+ implementation("com.squareup.retrofit2:converter-jackson:3.0.0")
+ implementation("com.squareup.retrofit2:converter-scalars:3.0.0")
+ implementation("com.squareup.retrofit2:retrofit:3.0.0")
+ implementation("com.xenomachina:kotlin-argparser:2.0.7")
+ implementation("io.ktor:ktor-network:3.3.3")
+ implementation("javax.ws.rs:javax.ws.rs-api:2.0")
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
+ implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.7.1")
+ implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.10.0")
+ implementation("org.msgpack:msgpack-core:0.9.11")
+ implementation("org.msgpack:jackson-dataformat-msgpack:0.9.11")
+
+ testImplementation(kotlin("test"))
+}
+
+openApiGenerate {
+ generatorName = "java"
+ library = "retrofit2"
+
+ remoteInputSpec = "https://airflow.apache.org/schemas/execution-api/$airflowExecApiVersion.json"
+ apiPackage = "org.apache.airflow.sdk.execution.api.route"
+ modelPackage = "org.apache.airflow.sdk.execution.api.model"
+ invokerPackage = "org.apache.airflow.sdk.execution.api.client"
+
+ generateApiDocumentation = false
+ generateApiTests = false
+ generateModelDocumentation = false
+ generateModelTests = false
+
+ // The spec on arbitrary mapping (e.g. 'extra') causes the OpenAPI generator to output JsonValue.
+ // We should probably fix the spec instead, but this should work before that.
+ // Suggested fix:
+ // type: object
+ // additionalProperties: true
+ schemaMappings.put("JsonValue", "java.lang.Object")
+
+ additionalProperties =
+ mapOf(
+ "dateLibrary" to "java8",
+ "openApiNullable" to false,
+ "serializationLibrary" to "jackson",
+ "withXml" to false,
+ )
+}
+
+sourceSets {
+ main {
+ java.srcDir(layout.buildDirectory.dir("generate-resources/main/src/main/java"))
+ kotlin.srcDir(constantsDir)
+ }
+}
+
+abstract class GenerateConstantsTask : DefaultTask() {
+ @get:Input
+ abstract val airflowExecApiVersionProp: Property
+
+ @get:OutputDirectory
+ abstract val outputDirProp: DirectoryProperty
+
+ @TaskAction
+ fun generate() {
+ val dir = outputDirProp.get().asFile.resolve("org/apache/airflow/sdk/execution")
+ dir.mkdirs()
+ dir.resolve("BuildConstants.kt").writeText(
+ """
+ // File generated at ${ZonedDateTime.now()}
+ package org.apache.airflow.sdk.execution
+
+ const val AIRFLOW_EXEC_API_VERSION = "${airflowExecApiVersionProp.get()}"
+ """.trimIndent() + "\n",
+ )
+ }
+}
+
+tasks.register("generateConstants") {
+ airflowExecApiVersionProp = airflowExecApiVersion
+ outputDirProp = constantsDir
+}
+
+tasks.named("compileJava") {
+ dependsOn("openApiGenerate")
+}
+
+tasks.named("compileKotlin") {
+ dependsOn("openApiGenerate")
+ dependsOn("generateConstants")
+}
+
+tasks.named("runKtlintCheckOverMainSourceSet") {
+ dependsOn("openApiGenerate")
+ dependsOn("generateConstants")
+}
+
+tasks.named("test") {
+ useJUnitPlatform()
+}
diff --git a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/Bundle.kt b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/Bundle.kt
new file mode 100644
index 0000000000000..56a4f0f65343c
--- /dev/null
+++ b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/Bundle.kt
@@ -0,0 +1,18 @@
+package org.apache.airflow.sdk
+
+class Bundle(
+ val version: String,
+ dags: Iterable,
+) {
+ val dags: Map = dags.associateByDagId()
+}
+
+private fun Iterable.associateByDagId(): Map {
+ val dagMap = linkedMapOf()
+ for (dag in this) {
+ require(dagMap.putIfAbsent(dag.dagId, dag) == null) {
+ "Duplicate dagId in bundle: ${dag.dagId}"
+ }
+ }
+ return dagMap
+}
diff --git a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/BundleInspector.kt b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/BundleInspector.kt
new file mode 100644
index 0000000000000..2da6b34c19829
--- /dev/null
+++ b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/BundleInspector.kt
@@ -0,0 +1,40 @@
+package org.apache.airflow.sdk
+
+import java.io.File
+
+/**
+ * Build-time utility that inspects a [DagBundle] implementation and writes
+ * dag_ids and task_ids to a YAML metadata file for inclusion in the JAR.
+ *
+ * Usage: {@code java -cp org.apache.airflow.sdk.BundleInspector }
+ */
+object BundleInspector {
+ @JvmStatic
+ fun main(args: Array) {
+ require(args.size == 2) { "Usage: BundleInspector " }
+ val className = args[0]
+ val outputPath = args[1]
+
+ val clazz = Class.forName(className)
+ val instance =
+ clazz.getDeclaredConstructor().newInstance() as? DagBundle
+ ?: error("$className does not implement ${DagBundle::class.qualifiedName}")
+ val dags = instance.getDags()
+
+ val outputFile = File(outputPath)
+ outputFile.parentFile.mkdirs()
+ outputFile.writeText(toYaml(dags))
+ }
+
+ internal fun toYaml(dags: List): String =
+ buildString {
+ appendLine("dags:")
+ for (dag in dags) {
+ appendLine(" ${dag.dagId}:")
+ appendLine(" tasks:")
+ for (taskId in dag.tasks.keys) {
+ appendLine(" - $taskId")
+ }
+ }
+ }
+}
diff --git a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/BundleScanner.kt b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/BundleScanner.kt
new file mode 100644
index 0000000000000..32fc591f7003c
--- /dev/null
+++ b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/BundleScanner.kt
@@ -0,0 +1,117 @@
+package org.apache.airflow.sdk
+
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.fasterxml.jackson.dataformat.yaml.YAMLFactory
+import org.apache.airflow.sdk.execution.containsJars
+import org.apache.airflow.sdk.execution.isJarFile
+import org.apache.airflow.sdk.execution.jarFiles
+import java.io.File
+import java.nio.file.Files
+import java.nio.file.Path
+import java.util.jar.JarFile
+
+const val METADATA_MANIFEST_KEY = "Airflow-Java-SDK-Metadata"
+
+private val yamlMapper = ObjectMapper(YAMLFactory())
+
+/**
+ * A fully resolved bundle: everything needed to start the bundle process.
+ */
+data class ResolvedBundle(
+ val mainClass: String,
+ val classpath: String,
+)
+
+/**
+ * Scans [bundlesDir] for Java DAG bundles by checking JAR manifests for the
+ * [METADATA_MANIFEST_KEY] attribute and reading the referenced YAML metadata.
+ *
+ * Supports two layouts:
+ * - **Nested**: each immediate subdirectory of [bundlesDir] is a bundle home.
+ * - **Flat**: [bundlesDir] itself contains the bundle JARs.
+ *
+ * Returns a mapping from dag_id to a [ResolvedBundle] with mainClass and classpath.
+ */
+fun scanBundles(bundlesDir: Path): Map {
+ if (!Files.isDirectory(bundlesDir)) return emptyMap()
+ val result = mutableMapOf()
+
+ // Check each immediate subdirectory as a potential bundle home.
+ Files.list(bundlesDir).use { paths ->
+ paths.filter { Files.isDirectory(it) }.forEach { candidate ->
+ collectBundleDags(candidate, result)
+ }
+ }
+
+ // Also check bundlesDir itself (flat layout).
+ collectBundleDags(bundlesDir, result)
+
+ return result
+}
+
+private fun collectBundleDags(
+ candidate: Path,
+ result: MutableMap,
+) {
+ val bundleHome = normalizeBundleHome(candidate)
+ val resolved = resolveBundle(bundleHome) ?: return
+ for (dagId in resolved.first) {
+ result.putIfAbsent(dagId, resolved.second)
+ }
+}
+
+/**
+ * Inspects JARs in [bundleHome] for [METADATA_MANIFEST_KEY] and Main-Class.
+ * Returns (dagIds, ResolvedBundle) or null if no JAR carries the metadata attribute.
+ */
+private fun resolveBundle(bundleHome: Path): Pair, ResolvedBundle>? {
+ val jars = jarFiles(bundleHome)
+ if (jars.isEmpty()) return null
+
+ for (jarPath in jars) {
+ JarFile(jarPath.toFile()).use { jar ->
+ val attrs = jar.manifest?.mainAttributes ?: return@use
+ val metadataFile = attrs.getValue(METADATA_MANIFEST_KEY) ?: return@use
+ val mainClass = attrs.getValue("Main-Class") ?: return@use
+ val entry = jar.getJarEntry(metadataFile) ?: return@use
+ val content = jar.getInputStream(entry).bufferedReader().readText()
+ val dagIds = parseDagIdsFromYaml(content)
+ if (dagIds.isEmpty()) return@use
+
+ val classpath =
+ jars
+ .map { it.toAbsolutePath().normalize().toString() }
+ .joinToString(File.pathSeparator)
+
+ return dagIds to ResolvedBundle(mainClass, classpath)
+ }
+ }
+ return null
+}
+
+fun readBundleDagIds(bundleHome: Path): Set {
+ for (jarPath in jarFiles(bundleHome)) {
+ JarFile(jarPath.toFile()).use { jar ->
+ val metadataFile = jar.manifest?.mainAttributes?.getValue(METADATA_MANIFEST_KEY) ?: return@use
+ val entry = jar.getJarEntry(metadataFile) ?: return@use
+ val content = jar.getInputStream(entry).bufferedReader().readText()
+ return parseDagIdsFromYaml(content)
+ }
+ }
+ return emptySet()
+}
+
+fun parseDagIdsFromYaml(yaml: String): Set {
+ val root = yamlMapper.readTree(yaml)
+ val dagsNode = root.get("dags") ?: return emptySet()
+ val dagIds = mutableSetOf()
+ dagsNode.fieldNames().forEachRemaining { dagIds.add(it) }
+ return dagIds
+}
+
+private fun normalizeBundleHome(path: Path): Path {
+ val normalized = path.toAbsolutePath().normalize()
+ if (normalized.isJarFile()) return normalized.parent
+ val lib = normalized.resolve("lib")
+ return if (containsJars(lib)) lib else normalized
+}
diff --git a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/Client.kt b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/Client.kt
new file mode 100644
index 0000000000000..582496ff0e2e0
--- /dev/null
+++ b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/Client.kt
@@ -0,0 +1,59 @@
+package org.apache.airflow.sdk
+
+import org.apache.airflow.sdk.execution.Client
+import org.apache.airflow.sdk.execution.StartupDetails
+
+class Client(
+ val details: StartupDetails,
+ val impl: Client,
+) {
+ companion object {
+ const val XCOM_RETURN_KEY = "return_value"
+ }
+
+ fun getConnection(id: String): Connection =
+ with(impl.getConnection(id)) {
+ Connection(
+ id = connId,
+ type = connType,
+ host = host,
+ schema = schema,
+ login = login,
+ password = password,
+ port = port,
+ extra = extra,
+ )
+ }
+
+ fun getVariable(key: String): Any? = impl.getVariable(key).value
+
+ @JvmOverloads fun getXCom(
+ key: String = XCOM_RETURN_KEY,
+ dagId: String = details.ti.dagId,
+ taskId: String,
+ runId: String = details.ti.runId,
+ mapIndex: Int? = null,
+ includePriorDates: Boolean = false,
+ ): Any? =
+ impl
+ .getXCom(
+ key = key,
+ dagId = dagId,
+ taskId = taskId,
+ runId = runId,
+ mapIndex = mapIndex,
+ includePriorDates = includePriorDates,
+ ).value
+
+ @JvmOverloads fun setXCom(
+ key: String = XCOM_RETURN_KEY,
+ value: Any,
+ ) = impl.setXCom(
+ key = key,
+ value = value,
+ dagId = details.ti.dagId,
+ taskId = details.ti.taskId,
+ runId = details.ti.runId,
+ mapIndex = details.ti.mapIndex ?: -1,
+ )
+}
diff --git a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/Config.kt b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/Config.kt
new file mode 100644
index 0000000000000..1a6c69b2b901f
--- /dev/null
+++ b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/Config.kt
@@ -0,0 +1,136 @@
+package org.apache.airflow.sdk
+
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.fasterxml.jackson.dataformat.yaml.YAMLFactory
+import java.nio.file.Files
+import java.nio.file.Path
+import java.nio.file.Paths
+
+private const val CONFIG_FILE_NAME = "java-sdk.yaml"
+
+open class WorkerError(
+ message: String,
+) : IllegalStateException(message)
+
+class NoBody : WorkerError("No body")
+
+/**
+ * SDK configuration resolved from environment variables and an optional YAML config file.
+ *
+ * Resolution order (highest priority first):
+ * 1. Environment variable `AIRFLOW____` (uppercase, double-underscore delimited)
+ * 2. YAML config file value at `.` (lowercase)
+ * 3. Default value (where applicable)
+ *
+ * Only the canonical `AIRFLOW____` env var form is recognised.
+ * Single-underscore variants (`AIRFLOW_SECTION_KEY`) are **not** supported — use the
+ * YAML file for a more readable alternative.
+ *
+ * The YAML file is loaded from `$AIRFLOW_HOME/java-sdk.yaml` when present.
+ *
+ * ```yaml
+ * core:
+ * execution_api_server_url: "http://localhost:8080/execution/"
+ *
+ * sdk:
+ * bundles_dir: "./bin"
+ *
+ * api_auth:
+ * jwt_secret: "your-secret-key"
+ * jwt_issuer: "airflow"
+ * jwt_expiration_time: 30
+ * ```
+ *
+ * Each YAML key corresponds directly to the env-var option name:
+ * `core.execution_api_server_url` ↔ `AIRFLOW__CORE__EXECUTION_API_SERVER_URL`.
+ */
+class SdkConfig(
+ private val env: Map = System.getenv(),
+ yamlOverride: Path? = null,
+) {
+ @Suppress("UNCHECKED_CAST")
+ private val yaml: Map> =
+ run {
+ val path = yamlOverride ?: resolveConfigPath(env)
+ if (path != null && Files.isRegularFile(path)) {
+ val raw = ObjectMapper(YAMLFactory()).readValue(path.toFile(), Map::class.java) as? Map ?: emptyMap()
+ raw.entries.associate { (k, v) ->
+ k to ((v as? Map<*, *>)?.entries?.associate { (ik, iv) -> ik.toString() to iv } ?: emptyMap())
+ }
+ } else {
+ emptyMap()
+ }
+ }
+
+ /**
+ * Look up a config value by section and key.
+ * Checks `AIRFLOW____` env var first, then YAML `.`.
+ */
+ fun get(
+ section: String,
+ key: String,
+ ): String? {
+ val envKey = "AIRFLOW__${section.uppercase()}__${key.uppercase()}"
+ env[envKey]?.takeIf { it.isNotBlank() }?.let { return it }
+ return yaml[section]?.get(key)?.toString()?.takeIf { it.isNotBlank() }
+ }
+
+ /** Like [get] but throws [WorkerError] when the value is missing. */
+ fun require(
+ section: String,
+ key: String,
+ ): String =
+ get(section, key)
+ ?: throw WorkerError(
+ "$section.$key must be configured " +
+ "(AIRFLOW__${section.uppercase()}__${key.uppercase()} or $CONFIG_FILE_NAME)",
+ )
+
+ /** Resolve a positive long, falling back to [default]. */
+ fun getPositiveLong(
+ section: String,
+ key: String,
+ default: Long,
+ ): Long {
+ val raw = get(section, key) ?: return default
+ val parsed =
+ raw.toLongOrNull()
+ ?: throw WorkerError("$section.$key must be an integer")
+ if (parsed <= 0) throw WorkerError("$section.$key must be greater than 0")
+ return parsed
+ }
+
+ // -- Execution API --
+
+ val executionApiUrl: String
+ get() {
+ val url =
+ get("core", "execution_api_server_url")
+ ?: get("execution", "api_url")
+ return url?.ensureTrailingSlash()
+ ?: throw WorkerError(
+ "core.execution_api_server_url must be configured " +
+ "(AIRFLOW__CORE__EXECUTION_API_SERVER_URL or $CONFIG_FILE_NAME)",
+ )
+ }
+
+ // -- JWT --
+
+ val jwtSecret: String get() = require("api_auth", "jwt_secret")
+ val jwtIssuer: String? get() = get("api_auth", "jwt_issuer")
+ val jwtExpirationTime: Long get() = getPositiveLong("api_auth", "jwt_expiration_time", 30)
+
+ // -- Bundle resolution --
+
+ val bundlesDir: Path?
+ get() = get("sdk", "bundles_dir")?.let(Paths::get)
+
+ companion object {
+ private fun resolveConfigPath(env: Map): Path? {
+ val home = env["AIRFLOW_HOME"]?.takeIf { it.isNotBlank() } ?: return null
+ return Paths.get(home, CONFIG_FILE_NAME)
+ }
+ }
+}
+
+internal fun String.ensureTrailingSlash() = if (endsWith('/')) this else "$this/"
diff --git a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/Connection.kt b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/Connection.kt
new file mode 100644
index 0000000000000..98a7dab39bded
--- /dev/null
+++ b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/Connection.kt
@@ -0,0 +1,12 @@
+package org.apache.airflow.sdk
+
+data class Connection(
+ val id: String,
+ val type: String?,
+ val host: String?,
+ val schema: String?,
+ val login: String?,
+ val password: String?,
+ val port: Int?,
+ val extra: String?,
+)
diff --git a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/Dag.kt b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/Dag.kt
new file mode 100644
index 0000000000000..0df3f1251f105
--- /dev/null
+++ b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/Dag.kt
@@ -0,0 +1,86 @@
+package org.apache.airflow.sdk
+
+import java.time.Duration
+import java.time.Instant
+
+/**
+ * A Dag (Directed Acyclic Graph) is a collection of tasks with directional dependencies.
+ *
+ * A Dag has a schedule, a start date and an end date (optional). For each schedule
+ * (say daily or hourly), the Dag needs to run each individual task as their
+ * dependencies are met.
+ *
+ * @param dagId The id of the Dag; must consist exclusively of alphanumeric characters,
+ * dashes, dots and underscores (all ASCII).
+ * @param description The description for the Dag to e.g. be shown on the webserver.
+ * @param schedule Defines when Dag runs are scheduled. Can be a cron expression string
+ * (e.g. "0 0 * * *"), a preset (e.g. "@daily", "@hourly", "@once", "@continuous"),
+ * or null for no schedule.
+ * @param startDate The timestamp from which the scheduler will attempt to backfill.
+ * @param endDate A date beyond which your Dag won't run; leave null for open-ended scheduling.
+ * @param defaultArgs A map of default parameters to be used as constructor keyword
+ * parameters when initialising operators.
+ * @param maxActiveTasks The number of task instances allowed to run concurrently per Dag run.
+ * @param maxActiveRuns Maximum number of active Dag runs.
+ * @param maxConsecutiveFailedDagRuns Maximum number of consecutive failed Dag runs,
+ * beyond this the scheduler will disable the Dag.
+ * @param dagrunTimeout Duration a DagRun is allowed to run before it times out or fails.
+ * @param catchup Perform scheduler catchup (or only run latest)? Defaults to false.
+ * @param docMd Documentation in markdown format.
+ * @param accessControl Optional Dag-level access control actions, e.g.
+ * mapOf("role1" to mapOf("DAGs" to setOf("can_read", "can_edit"))).
+ * @param isPausedUponCreation Whether the Dag is paused when created for the first time.
+ * @param tags Set of tags to help filtering Dags in the UI.
+ * @param ownerLinks Map of owners and their links, clickable on the Dags view UI.
+ * @param failFast Fails currently running tasks when a task in Dag fails.
+ * @param dagDisplayName The display name of the Dag on the UI. Defaults to dagId.
+ * @param renderTemplateAsNativeObj If true, uses native rendering for templates.
+ * @param params A map of Dag-level parameters accessible in templates, namespaced under
+ * "params". These can be overridden at the task level.
+ */
+class Dag
+ @JvmOverloads
+ constructor(
+ val dagId: String,
+ val description: String? = null,
+ val schedule: String? = null,
+ val startDate: Instant? = null,
+ val endDate: Instant? = null,
+ val defaultArgs: Map = emptyMap(),
+ val maxActiveTasks: Int = DEFAULT_MAX_ACTIVE_TASKS,
+ val maxActiveRuns: Int = DEFAULT_MAX_ACTIVE_RUNS,
+ val maxConsecutiveFailedDagRuns: Int = DEFAULT_MAX_CONSECUTIVE_FAILED_DAG_RUNS,
+ val dagrunTimeout: Duration? = null,
+ val catchup: Boolean = false,
+ val docMd: String? = null,
+ val accessControl: Map>>? = null,
+ val isPausedUponCreation: Boolean? = null,
+ val tags: Set = emptySet(),
+ val ownerLinks: Map = emptyMap(),
+ val failFast: Boolean = false,
+ val dagDisplayName: String? = null,
+ val renderTemplateAsNativeObj: Boolean = false,
+ val params: Map? = null,
+ ) {
+ internal var tasks = mutableMapOf>()
+ internal var dependants = mutableMapOf>()
+
+ @JvmOverloads
+ fun addTask(
+ id: String,
+ definition: Class,
+ dependsOn: List = emptyList(),
+ ) {
+ // TODO: Check duplicate key.
+ tasks[id] = definition
+ for (parent in dependsOn) {
+ dependants.getOrPut(parent) { mutableSetOf() }.add(id)
+ }
+ }
+
+ companion object {
+ const val DEFAULT_MAX_ACTIVE_TASKS = 16
+ const val DEFAULT_MAX_ACTIVE_RUNS = 16
+ const val DEFAULT_MAX_CONSECUTIVE_FAILED_DAG_RUNS = 0
+ }
+ }
diff --git a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/DagBundle.kt b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/DagBundle.kt
new file mode 100644
index 0000000000000..9890bd20b6076
--- /dev/null
+++ b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/DagBundle.kt
@@ -0,0 +1,12 @@
+package org.apache.airflow.sdk
+
+/**
+ * Interface for declaring DAGs in a bundle.
+ *
+ * Implement this interface in the class specified as {@code Main-Class} in your JAR manifest.
+ * The build system instantiates this class at compile time to extract dag_ids and task_ids
+ * into the JAR manifest, enabling inspection of bundled DAGs without running the full process.
+ */
+interface DagBundle {
+ fun getDags(): List
+}
diff --git a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/Server.kt b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/Server.kt
new file mode 100644
index 0000000000000..bc72bfa3296cf
--- /dev/null
+++ b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/Server.kt
@@ -0,0 +1,79 @@
+package org.apache.airflow.sdk
+
+import com.xenomachina.argparser.ArgParser
+import io.ktor.network.selector.SelectorManager
+import io.ktor.network.sockets.InetSocketAddress
+import io.ktor.network.sockets.aSocket
+import io.ktor.network.sockets.openReadChannel
+import io.ktor.network.sockets.openWriteChannel
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import org.apache.airflow.sdk.execution.CoordinatorComm
+import org.apache.airflow.sdk.execution.LogSender
+import org.apache.airflow.sdk.execution.Logger
+import kotlin.text.substringAfterLast
+import kotlin.text.substringBeforeLast
+
+private class Args(
+ parser: ArgParser,
+) {
+ private fun parseAddress(address: String): InetSocketAddress =
+ InetSocketAddress(
+ address.substringBeforeLast(':'),
+ address.substringAfterLast(':').toInt(),
+ )
+
+ val comm by parser.storing("--comm", help = "Address (host:port) to communicate with parent") {
+ parseAddress(this)
+ }
+ val logs by parser.storing("--logs", help = "Address (host:port) to send Airflow logs to") {
+ parseAddress(this)
+ }
+}
+
+class ApiError(
+ message: String,
+) : IllegalStateException(message)
+
+class Server(
+ private val comm: InetSocketAddress,
+ private val logs: InetSocketAddress,
+) {
+ companion object {
+ @JvmStatic
+ fun create(args: Array): Server {
+ val args = ArgParser(args).parseInto(::Args)
+ return Server(args.comm, args.logs)
+ }
+ }
+
+ private val logger = Logger(Server::class)
+
+ fun serve(bundle: Bundle) {
+ runBlocking {
+ launch {
+ awaitAll(
+ async {
+ aSocket(SelectorManager(Dispatchers.IO)).tcp().connect(comm).use { socket ->
+ logger.debug("Connected comm", mapOf("addr" to comm))
+ CoordinatorComm(
+ bundle,
+ socket.openReadChannel(),
+ socket.openWriteChannel(autoFlush = true),
+ ).startProcessing()
+ }
+ },
+ async {
+ aSocket(SelectorManager(Dispatchers.IO)).tcp().connect(logs).use { socket ->
+ logger.debug("Connected logs", mapOf("addr" to logs))
+ LogSender.configure(socket.openWriteChannel(autoFlush = true))
+ }
+ },
+ )
+ }
+ }
+ }
+}
diff --git a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/Task.kt b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/Task.kt
new file mode 100644
index 0000000000000..08ca52e2ae5c1
--- /dev/null
+++ b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/Task.kt
@@ -0,0 +1,8 @@
+package org.apache.airflow.sdk
+
+import kotlin.Throws
+
+interface Task {
+ @Throws(Exception::class)
+ fun execute(client: Client)
+}
diff --git a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/Client.kt b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/Client.kt
new file mode 100644
index 0000000000000..7388b308e6d3c
--- /dev/null
+++ b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/Client.kt
@@ -0,0 +1,143 @@
+package org.apache.airflow.sdk.execution
+
+import kotlinx.coroutines.runBlocking
+import org.apache.airflow.sdk.execution.api.client.ApiClient
+import org.apache.airflow.sdk.execution.api.model.ConnectionResponse
+import org.apache.airflow.sdk.execution.api.model.VariableResponse
+import org.apache.airflow.sdk.execution.api.model.XComResponse
+import org.apache.airflow.sdk.execution.api.route.ConnectionsApi
+import org.apache.airflow.sdk.execution.api.route.VariablesApi
+import org.apache.airflow.sdk.execution.api.route.XComsApi
+import java.time.LocalDate
+
+interface Client {
+ fun getConnection(id: String): ConnectionResponse
+
+ fun getVariable(key: String): VariableResponse
+
+ fun getXCom(
+ key: String,
+ dagId: String,
+ taskId: String,
+ runId: String,
+ mapIndex: Int? = null,
+ includePriorDates: Boolean = false,
+ ): XComResponse
+
+ fun setXCom(
+ key: String,
+ value: Any,
+ dagId: String,
+ taskId: String,
+ runId: String,
+ mapIndex: Int,
+ )
+}
+
+class CoordinatorClient(
+ val exec: CoordinatorComm,
+) : Client {
+ override fun getConnection(id: String) = runBlocking { exec.communicate(GetConnection(id)) }
+
+ override fun getVariable(key: String) = runBlocking { exec.communicate(GetVariable(key)) }
+
+ override fun setXCom(
+ key: String,
+ value: Any,
+ dagId: String,
+ taskId: String,
+ runId: String,
+ mapIndex: Int,
+ ) {
+ val message =
+ SetXCom(
+ key = key,
+ value = value,
+ dagId = dagId,
+ taskId = taskId,
+ runId = runId,
+ mapIndex = mapIndex,
+ )
+ runBlocking { exec.communicate(message) }
+ }
+
+ override fun getXCom(
+ key: String,
+ dagId: String,
+ taskId: String,
+ runId: String,
+ mapIndex: Int?,
+ includePriorDates: Boolean,
+ ): XComResponse {
+ val message =
+ GetXCom(
+ key = key,
+ dagId = dagId,
+ taskId = taskId,
+ runId = runId,
+ mapIndex = mapIndex,
+ includePriorDates = includePriorDates,
+ )
+ return runBlocking { exec.communicate(message) }
+ }
+}
+
+class HttpExecApiClient(
+ val http: ApiClient,
+) : Client {
+ companion object {
+ val version: LocalDate = LocalDate.parse(AIRFLOW_EXEC_API_VERSION)
+ }
+
+ override fun getConnection(id: String) =
+ http.communicate {
+ getConnection(id, version)
+ }
+
+ override fun getVariable(key: String) =
+ http.communicate {
+ getVariable(key, version)
+ }
+
+ override fun getXCom(
+ key: String,
+ dagId: String,
+ taskId: String,
+ runId: String,
+ mapIndex: Int?,
+ includePriorDates: Boolean,
+ ) = http.communicate {
+ getXcom(
+ dagId,
+ runId,
+ taskId,
+ key,
+ mapIndex,
+ includePriorDates,
+ 0,
+ version,
+ )
+ }
+
+ override fun setXCom(
+ key: String,
+ value: Any,
+ dagId: String,
+ taskId: String,
+ runId: String,
+ mapIndex: Int,
+ ) {
+ http.communicate {
+ setXcom(
+ dagId,
+ runId,
+ taskId,
+ key,
+ mapIndex,
+ null,
+ version,
+ value,
+ )
+ }
+ }
+}
diff --git a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/Comms.kt b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/Comms.kt
new file mode 100644
index 0000000000000..acc0e5bb4154f
--- /dev/null
+++ b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/Comms.kt
@@ -0,0 +1,240 @@
+package org.apache.airflow.sdk.execution
+
+import com.fasterxml.jackson.annotation.JsonProperty
+import com.fasterxml.jackson.annotation.JsonPropertyOrder
+import io.ktor.utils.io.ByteReadChannel
+import io.ktor.utils.io.ByteWriteChannel
+import io.ktor.utils.io.readByteArray
+import io.ktor.utils.io.writeByteArray
+import org.apache.airflow.sdk.ApiError
+import org.apache.airflow.sdk.Bundle
+import org.apache.airflow.sdk.execution.api.client.ApiClient
+import org.apache.airflow.sdk.execution.api.model.AssetProfile
+import org.apache.airflow.sdk.execution.api.model.BundleInfo
+import org.apache.airflow.sdk.execution.api.model.TIRunContext
+import org.apache.airflow.sdk.execution.api.model.TISuccessStatePayload
+import org.apache.airflow.sdk.execution.api.model.TaskInstance
+import retrofit2.Call
+import java.time.OffsetDateTime
+import kotlin.concurrent.atomics.AtomicInt
+import kotlin.concurrent.atomics.ExperimentalAtomicApi
+import kotlin.system.exitProcess
+
+data class IncomingFrame(
+ val id: Int,
+ val body: Any?,
+)
+
+data class OutgoingFrame(
+ val id: Int,
+ val body: Any,
+)
+
+class ErrorResponse {
+ @JsonProperty("error")
+ var error: String = "" // TODO: Use enum.
+
+ @JsonProperty("detail")
+ var detail: Any? = null
+}
+
+class DagFileParseRequest {
+ var file: String = ""
+
+ @JsonProperty("bundle_path")
+ var bundlePath: String = ""
+}
+
+class StartupDetails {
+ @JsonProperty("ti")
+ lateinit var ti: TaskInstance
+
+ @JsonProperty("dag_rel_path")
+ var dagRelPath: String = ""
+
+ @JsonProperty("bundle_info")
+ lateinit var bundleInfo: BundleInfo
+
+ @JsonProperty("start_date")
+ lateinit var startDate: OffsetDateTime
+
+ @JsonProperty("ti_context")
+ lateinit var tiContext: TIRunContext
+
+ @JsonProperty("sentry_integration")
+ var sentryIntegration: String = ""
+}
+
+class SucceedTask : TISuccessStatePayload {
+ constructor(
+ endDate: OffsetDateTime = OffsetDateTime.now(),
+ taskOutlets: List = emptyList(),
+ outletEvents: List