This is an automated email from the ASF dual-hosted git repository.
jason810496 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new 20229e47c99 Java SDK: Clearer error when a task class cannot be
instantiated (#69827)
20229e47c99 is described below
commit 20229e47c9966359967e269532916c1ba3ad2630
Author: PoAn Yang <[email protected]>
AuthorDate: Tue Aug 25 00:58:53 2026 +0900
Java SDK: Clearer error when a task class cannot be instantiated (#69827)
Signed-off-by: PoAn Yang <[email protected]>
---
.../language-sdks/java.rst | 23 +++-
airflow-e2e-tests/docker/java.yml | 12 +-
airflow-e2e-tests/java-test-bundle/.gitignore | 2 +
airflow-e2e-tests/java-test-bundle/build.gradle | 49 ++++++++
.../gradle.properties} | 19 +--
airflow-e2e-tests/java-test-bundle/settings.gradle | 33 ++++++
.../org/apache/airflow/e2e/TestBundleBuilder.java | 62 ++++++++++
.../src/resources/dags/java_test_dags.py} | 37 +++---
.../tests/airflow_e2e_tests/conftest.py | 45 ++++---
.../tests/airflow_e2e_tests/constants.py | 7 ++
.../java_sdk_tests/test_java_sdk_dag.py | 130 +++++++++++++++++----
dev/breeze/doc/ci/04_selective_checks.md | 7 ++
.../src/airflow_breeze/utils/selective_checks.py | 1 +
dev/breeze/tests/test_selective_checks.py | 9 ++
.../org/apache/airflow/sdk/execution/Task.kt | 37 ++++--
.../org/apache/airflow/sdk/execution/TaskTest.kt | 119 +++++++++++++++++++
16 files changed, 509 insertions(+), 83 deletions(-)
diff --git a/airflow-core/docs/authoring-and-scheduling/language-sdks/java.rst
b/airflow-core/docs/authoring-and-scheduling/language-sdks/java.rst
index a3e27f03132..7fd346bf3f4 100644
--- a/airflow-core/docs/authoring-and-scheduling/language-sdks/java.rst
+++ b/airflow-core/docs/authoring-and-scheduling/language-sdks/java.rst
@@ -205,6 +205,17 @@ Interface-based API
Implement the ``Task`` interface directly for full control over how tasks are
registered and how XComs are
read. Each task is registered as a ``TaskDef`` on a ``DagDef``.
+The runner creates a fresh instance of the task class through reflection for
every task-instance run,
+which puts four constraints on the class:
+
+* The task class itself must be ``public``.
+* It must be concrete: not abstract and not an interface.
+* It must declare a public no-argument constructor.
+* If nested inside another class, it must be a ``static`` nested class.
+
+A class that violates any of these fails at runtime with a ``Cannot
instantiate task class`` error in the
+task log.
+
.. code-block:: java
import org.apache.airflow.sdk.*;
@@ -218,11 +229,21 @@ read. Each task is registered as a ``TaskDef`` on a
``DagDef``.
}
}
-Register tasks manually in a ``BundleBuilder``:
+Register tasks manually in a ``BundleBuilder``. A task class can be top-level
like ``FetchTask``, or
+nested ``static`` class like ``ProcessTask``:
.. code-block:: java
public class MyBundle implements BundleBuilder {
+ public static class ProcessTask implements Task {
+ @Override
+ public void execute(Context context, Client client) throws Exception {
+ var fetched = (String) client.getXCom("fetch");
+ // implement task logic
+ client.setXCom(fetched);
+ }
+ }
+
@Override
public Iterable<DagDef> getDags() {
var dag = new DagDef("my_dag")
diff --git a/airflow-e2e-tests/docker/java.yml
b/airflow-e2e-tests/docker/java.yml
index d3609f4e8be..db4897f44c8 100644
--- a/airflow-e2e-tests/docker/java.yml
+++ b/airflow-e2e-tests/docker/java.yml
@@ -19,10 +19,11 @@
#
# Replaces the stock airflow-worker image with one that has a JRE installed
# (built by conftest._setup_java_sdk_integration via Dockerfile.java), mounts
-# the pre-built bundle JARs (the Java example under /opt/airflow/java-jars and
-# the Scala Spark example under /opt/airflow/scala-jars), and configures the
-# worker to consume the "java" and "scala" Celery queues where @task.stub tasks
-# are routed.
+# the pre-built bundle JARs (the Java example under /opt/airflow/java-jars, the
+# Scala Spark example under /opt/airflow/scala-jars, and the runner-behaviour
+# test fixtures under /opt/airflow/java-test-jars), and configures the worker
to
+# consume the "java", "scala", and "java-test" Celery queues where @task.stub
+# tasks are routed.
---
services:
airflow-worker:
@@ -30,4 +31,5 @@ services:
volumes:
- ./java-jars:/opt/airflow/java-jars:ro
- ./scala-jars:/opt/airflow/scala-jars:ro
- command: celery worker -q java,scala,default
+ - ./java-test-jars:/opt/airflow/java-test-jars:ro
+ command: celery worker -q java,scala,java-test,default
diff --git a/airflow-e2e-tests/java-test-bundle/.gitignore
b/airflow-e2e-tests/java-test-bundle/.gitignore
new file mode 100644
index 00000000000..7f6823bcc0f
--- /dev/null
+++ b/airflow-e2e-tests/java-test-bundle/.gitignore
@@ -0,0 +1,2 @@
+.gradle
+build/
diff --git a/airflow-e2e-tests/java-test-bundle/build.gradle
b/airflow-e2e-tests/java-test-bundle/build.gradle
new file mode 100644
index 00000000000..d826d2fe3f8
--- /dev/null
+++ b/airflow-e2e-tests/java-test-bundle/build.gradle
@@ -0,0 +1,49 @@
+/*
+ * 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.
+ */
+
+plugins {
+ id("org.apache.airflow.sdk") version "${projectVersion}"
+}
+
+repositories {
+ mavenLocal()
+ mavenCentral()
+}
+
+dependencies {
+ implementation("org.apache.airflow:airflow-sdk:${projectVersion}")
+ implementation("org.apache.airflow:airflow-sdk-jpl:${projectVersion}")
+}
+
+java {
+ toolchain {
+ languageVersion.set(JavaLanguageVersion.of(11))
+ }
+ sourceCompatibility = JavaVersion.VERSION_11
+}
+
+sourceSets {
+ main {
+ java.srcDir("src/java")
+ }
+}
+
+airflowBundle {
+ mainClass = "org.apache.airflow.e2e.TestBundleBuilder"
+}
diff --git a/airflow-e2e-tests/docker/java.yml
b/airflow-e2e-tests/java-test-bundle/gradle.properties
similarity index 53%
copy from airflow-e2e-tests/docker/java.yml
copy to airflow-e2e-tests/java-test-bundle/gradle.properties
index d3609f4e8be..c3c94e0057f 100644
--- a/airflow-e2e-tests/docker/java.yml
+++ b/airflow-e2e-tests/java-test-bundle/gradle.properties
@@ -15,19 +15,6 @@
# specific language governing permissions and limitations
# under the License.
-# Docker Compose override for java_sdk E2E test mode.
-#
-# Replaces the stock airflow-worker image with one that has a JRE installed
-# (built by conftest._setup_java_sdk_integration via Dockerfile.java), mounts
-# the pre-built bundle JARs (the Java example under /opt/airflow/java-jars and
-# the Scala Spark example under /opt/airflow/scala-jars), and configures the
-# worker to consume the "java" and "scala" Celery queues where @task.stub tasks
-# are routed.
----
-services:
- airflow-worker:
- image: airflow-java-worker
- volumes:
- - ./java-jars:/opt/airflow/java-jars:ro
- - ./scala-jars:/opt/airflow/scala-jars:ro
- command: celery worker -q java,scala,default
+org.gradle.configuration-cache=true
+
+projectVersion=1.0.0-SNAPSHOT
diff --git a/airflow-e2e-tests/java-test-bundle/settings.gradle
b/airflow-e2e-tests/java-test-bundle/settings.gradle
new file mode 100644
index 00000000000..1785aaf9168
--- /dev/null
+++ b/airflow-e2e-tests/java-test-bundle/settings.gradle
@@ -0,0 +1,33 @@
+/*
+ * 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.
+ */
+
+// Route the plugin lookup to the SDK build published to the local Maven
+// repository by conftest._setup_java_sdk_integration.
+pluginManagement {
+ repositories {
+ mavenLocal()
+ gradlePluginPortal()
+ }
+}
+
+plugins {
+ id("org.gradle.toolchains.foojay-resolver-convention") version "0.10.0"
+}
+
+rootProject.name = "airflow-e2e-java-test-bundle"
diff --git
a/airflow-e2e-tests/java-test-bundle/src/java/org/apache/airflow/e2e/TestBundleBuilder.java
b/airflow-e2e-tests/java-test-bundle/src/java/org/apache/airflow/e2e/TestBundleBuilder.java
new file mode 100644
index 00000000000..5986f9d57b0
--- /dev/null
+++
b/airflow-e2e-tests/java-test-bundle/src/java/org/apache/airflow/e2e/TestBundleBuilder.java
@@ -0,0 +1,62 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.airflow.e2e;
+
+import java.util.List;
+import org.apache.airflow.sdk.*;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * Bundle of deliberately broken task classes for the runner-behaviour E2E
tests.
+ */
+public class TestBundleBuilder implements BundleBuilder {
+ public static class MissingNoArgConstructor implements Task {
+ public MissingNoArgConstructor(String unused) {}
+
+ public void execute(@NotNull Context context, Client client) {
+ throw new IllegalStateException("should not be reachable");
+ }
+ }
+
+ /**
+ * A non-static nested class declares no constructor of its own, but the
implicit one
+ * takes the enclosing instance, so the runner's lookup for a no-argument
constructor
+ * fails.
+ */
+ public class NonStaticInner implements Task {
+ public void execute(@NotNull Context context, Client client) {
+ throw new IllegalStateException("should not be reachable");
+ }
+ }
+
+ @NotNull
+ @Override
+ public Iterable<DagDef> getDags() {
+ var dag = new DagDef("java_uninstantiable");
+ dag.addTask("missing_no_arg_constructor", MissingNoArgConstructor.class);
+ dag.addTask("non_static_inner", NonStaticInner.class);
+ return List.of(dag);
+ }
+
+ public static void main(String[] args) {
+ var bundle = new TestBundleBuilder().build();
+ Server.create(args).serve(bundle);
+ }
+}
diff --git a/airflow-e2e-tests/docker/java.yml
b/airflow-e2e-tests/java-test-bundle/src/resources/dags/java_test_dags.py
similarity index 53%
copy from airflow-e2e-tests/docker/java.yml
copy to airflow-e2e-tests/java-test-bundle/src/resources/dags/java_test_dags.py
index d3609f4e8be..09274b77cd9 100644
--- a/airflow-e2e-tests/docker/java.yml
+++ b/airflow-e2e-tests/java-test-bundle/src/resources/dags/java_test_dags.py
@@ -14,20 +14,25 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
+"""Stub Dags for the Java runner-behaviour E2E fixtures in the
java-test-bundle."""
-# Docker Compose override for java_sdk E2E test mode.
-#
-# Replaces the stock airflow-worker image with one that has a JRE installed
-# (built by conftest._setup_java_sdk_integration via Dockerfile.java), mounts
-# the pre-built bundle JARs (the Java example under /opt/airflow/java-jars and
-# the Scala Spark example under /opt/airflow/scala-jars), and configures the
-# worker to consume the "java" and "scala" Celery queues where @task.stub tasks
-# are routed.
----
-services:
- airflow-worker:
- image: airflow-java-worker
- volumes:
- - ./java-jars:/opt/airflow/java-jars:ro
- - ./scala-jars:/opt/airflow/scala-jars:ro
- command: celery worker -q java,scala,default
+from __future__ import annotations
+
+from airflow.sdk import dag, task
+
+
[email protected](queue="java-test")
+def missing_no_arg_constructor(): ...
+
+
[email protected](queue="java-test")
+def non_static_inner(): ...
+
+
+@dag(dag_id="java_uninstantiable")
+def java_uninstantiable():
+ missing_no_arg_constructor()
+ non_static_inner()
+
+
+java_uninstantiable()
diff --git a/airflow-e2e-tests/tests/airflow_e2e_tests/conftest.py
b/airflow-e2e-tests/tests/airflow_e2e_tests/conftest.py
index 5b45974f053..2877a4dc7bb 100644
--- a/airflow-e2e-tests/tests/airflow_e2e_tests/conftest.py
+++ b/airflow-e2e-tests/tests/airflow_e2e_tests/conftest.py
@@ -51,6 +51,9 @@ from airflow_e2e_tests.constants import (
JAVA_SDK_EXAMPLE_LIBS_PATH,
JAVA_SDK_MAVEN_CACHE_PATH,
JAVA_SDK_ROOT_PATH,
+ JAVA_TEST_BUNDLE_DAGS_PATH,
+ JAVA_TEST_BUNDLE_LIBS_PATH,
+ JAVA_TEST_BUNDLE_ROOT_PATH,
KAFKA_DIR_PATH,
LANG_SDK_NATIVE_TOOLCHAIN,
LOCALSTACK_PATH,
@@ -376,13 +379,14 @@ def _setup_java_sdk_integration(dot_env_file, tmp_dir):
console.print("[yellow]Publishing Java SDK artifacts to local Maven
repository...")
_run_java_sdk_gradle(JAVA_SDK_ROOT_PATH, "publishToMavenLocal",
"-PskipSigning=true", native=native)
- # The example and scala_spark_example are independent Gradle builds that
both
- # consume the SDK artifact published above, so build them concurrently.
Sharing
- # a writable Gradle user home between concurrent builds is safe because
each
- # build can ping the other's lock-owner port over one shared loopback - the
- # host's own in native mode, --network=host in the container path (see the
- # helper's docstring); publishToMavenLocal has already unpacked the shared
- # wrapper distribution, so neither build races to fetch it.
+ # The example, scala_spark_example, and java-test-bundle are independent
+ # Gradle builds that all consume the SDK artifact published above, so build
+ # them concurrently. Sharing a writable Gradle user home between concurrent
+ # builds is safe because each build can ping the other's lock-owner port
over
+ # one shared loopback - the host's own in native mode, --network=host in
the
+ # container path (see the helper's docstring); publishToMavenLocal has
+ # already unpacked the shared wrapper distribution, so no build races to
+ # fetch it.
#
# The Gradle `bundle` task is a Copy that never prunes its destination, so
# JARs from an earlier build linger. A stale dependency JAR with its own
@@ -390,11 +394,15 @@ def _setup_java_sdk_integration(dot_env_file, tmp_dir):
# start each bundle from an empty directory.
rmtree(JAVA_SDK_EXAMPLE_LIBS_PATH, ignore_errors=True)
rmtree(SCALA_SPARK_EXAMPLE_LIBS_PATH, ignore_errors=True)
+ rmtree(JAVA_TEST_BUNDLE_LIBS_PATH, ignore_errors=True)
toolchain = "host toolchain" if native else "eclipse-temurin:17-jdk"
- console.print(f"[yellow]Building Java SDK and Scala Spark example bundles
concurrently ({toolchain})...")
+ console.print(
+ f"[yellow]Building Java SDK, Scala Spark, and test-fixture bundles
concurrently ({toolchain})..."
+ )
example_bundle_workdirs = [
JAVA_SDK_ROOT_PATH / "example",
JAVA_SDK_ROOT_PATH / "scala_spark_example",
+ JAVA_TEST_BUNDLE_ROOT_PATH,
]
with ThreadPoolExecutor(max_workers=len(example_bundle_workdirs)) as pool:
bundle_builds = [
@@ -411,6 +419,7 @@ def _setup_java_sdk_integration(dot_env_file, tmp_dir):
# expose them to the worker, and each JavaCoordinator globs its own dir.
copytree(JAVA_SDK_EXAMPLE_LIBS_PATH, tmp_dir / "java-jars")
copytree(SCALA_SPARK_EXAMPLE_LIBS_PATH, tmp_dir / "scala-jars")
+ copytree(JAVA_TEST_BUNDLE_LIBS_PATH, tmp_dir / "java-test-jars")
# Copy the Java SDK example Dag files so Airflow can discover them.
copyfile(JAVA_SDK_EXAMPLE_DAGS_PATH / "java_examples.py", tmp_dir / "dags"
/ "java_examples.py")
@@ -418,12 +427,13 @@ def _setup_java_sdk_integration(dot_env_file, tmp_dir):
SCALA_SPARK_EXAMPLE_DAGS_PATH / "scala_spark_examples.py",
tmp_dir / "dags" / "scala_spark_examples.py",
)
+ copyfile(JAVA_TEST_BUNDLE_DAGS_PATH / "java_test_dags.py", tmp_dir /
"dags" / "java_test_dags.py")
# Keep the bundle JARs out of the build context: Dockerfile.java only adds
a
# JRE and copies nothing from the context, so without this docker build
would
# tar and stream the bundles (hundreds of MB of Spark JARs) to the daemon
for
# nothing. The JARs reach the worker via the compose bind-mounts, not the
image.
- (tmp_dir / ".dockerignore").write_text("java-jars/\nscala-jars/\n")
+ (tmp_dir /
".dockerignore").write_text("java-jars/\nscala-jars/\njava-test-jars/\n")
# Build a local Docker image that extends DOCKER_IMAGE with a JRE.
# We do this explicitly so testcontainers' DockerCompose.start() does not
@@ -445,10 +455,11 @@ def _setup_java_sdk_integration(dot_env_file, tmp_dir):
check=True,
)
- # Two JavaCoordinators on the same worker image, one bundle per queue. The
- # scala-jdk entry pins main_class (Spark's large classpath makes Main-Class
- # discovery ambiguous) and carries Spark's Java 17 module openings, a small
- # driver heap, and a longer startup timeout for its large dependency
classpath.
+ # One JavaCoordinator per queue on the same worker image, each serving its
+ # own bundle. The scala-jdk entry pins main_class (Spark's large classpath
+ # makes Main-Class discovery ambiguous) and carries Spark's Java 17 module
+ # openings, a small driver heap, and a longer startup timeout for its large
+ # dependency classpath.
coordinator_config = json.dumps(
{
"java-jdk": {
@@ -464,9 +475,15 @@ def _setup_java_sdk_integration(dot_env_file, tmp_dir):
"task_startup_timeout": 60.0,
},
},
+ "java-test-jdk": {
+ "classpath": "airflow.sdk.coordinators.java.JavaCoordinator",
+ "kwargs": {"jars_root": ["/opt/airflow/java-test-jars"]},
+ },
}
)
- queue_to_coordinator = json.dumps({"java": "java-jdk", "scala":
"scala-jdk"})
+ queue_to_coordinator = json.dumps(
+ {"java": "java-jdk", "scala": "scala-jdk", "java-test":
"java-test-jdk"}
+ )
# Connection expected by the Java example bundle tasks. The JSON form
# covers all connection fields, in particular the port: wire integers
diff --git a/airflow-e2e-tests/tests/airflow_e2e_tests/constants.py
b/airflow-e2e-tests/tests/airflow_e2e_tests/constants.py
index cbba1dde33f..4a1a2f7be38 100644
--- a/airflow-e2e-tests/tests/airflow_e2e_tests/constants.py
+++ b/airflow-e2e-tests/tests/airflow_e2e_tests/constants.py
@@ -77,6 +77,13 @@ SCALA_SPARK_EXAMPLE_DAGS_PATH = (
)
SCALA_SPARK_EXAMPLE_LIBS_PATH = JAVA_SDK_ROOT_PATH / "scala_spark_example" /
"build" / "bundle"
+# Java test-fixture bundle paths (deliberately broken task classes for the
+# runner-behaviour E2E tests; a separate bundle with its own coordinator/queue
+# so they stay out of the user-facing example).
+JAVA_TEST_BUNDLE_ROOT_PATH = AIRFLOW_ROOT_PATH / "airflow-e2e-tests" /
"java-test-bundle"
+JAVA_TEST_BUNDLE_DAGS_PATH = JAVA_TEST_BUNDLE_ROOT_PATH / "src" / "resources"
/ "dags"
+JAVA_TEST_BUNDLE_LIBS_PATH = JAVA_TEST_BUNDLE_ROOT_PATH / "build" / "bundle"
+
# Go SDK E2E test paths
GO_SDK_ROOT_PATH = AIRFLOW_ROOT_PATH / "go-sdk"
GO_SDK_DAGS_PATH = GO_SDK_ROOT_PATH / "dags"
diff --git
a/airflow-e2e-tests/tests/airflow_e2e_tests/java_sdk_tests/test_java_sdk_dag.py
b/airflow-e2e-tests/tests/airflow_e2e_tests/java_sdk_tests/test_java_sdk_dag.py
index e634ece89b7..c0ca18f0b37 100644
---
a/airflow-e2e-tests/tests/airflow_e2e_tests/java_sdk_tests/test_java_sdk_dag.py
+++
b/airflow-e2e-tests/tests/airflow_e2e_tests/java_sdk_tests/test_java_sdk_dag.py
@@ -71,6 +71,35 @@ _JAVA_TASK_TIMEOUT = 600
_LOG_FETCH_TIMEOUT = 60
+def _wait_for_task_log_record(
+ airflow_client: AirflowClient,
+ dag_id: str,
+ task_id: str,
+ run_id: str,
+ try_number: int,
+ match: Callable[[dict], bool],
+) -> tuple[dict | None, list[dict]]:
+ """Poll a task's logs until a record matching *match* appears.
+
+ Logs can lag behind the terminal task state, and earlier records arrive
+ before the one under test, so returning on any record would race. Keep
+ polling until the target record shows up or the deadline passes. Returns
+ the matching record (or ``None``) and the last batch of records seen for
+ diagnostics.
+ """
+ deadline = time.monotonic() + _LOG_FETCH_TIMEOUT
+ records: list[dict] = []
+ while True:
+ resp = airflow_client.get_task_logs(
+ dag_id=dag_id, run_id=run_id, task_id=task_id,
try_number=try_number
+ )
+ records = [entry for entry in resp.get("content", []) if
isinstance(entry, dict)]
+ record = next((r for r in records if match(r)), None)
+ if record is not None or time.monotonic() > deadline:
+ return record, records
+ time.sleep(3)
+
+
class TestJavaSDKAnnotationExample:
"""Verify the annotation-based Java SDK example executes correctly."""
@@ -225,29 +254,6 @@ class TestJavaSDKAnnotationExample:
f"try_number={load_ti.get('try_number')!r}, ti: {load_ti}"
)
- def _wait_for_transform_log_record(
- self, run_id: str, try_number: int, match: Callable[[dict], bool]
- ) -> tuple[dict | None, list[dict]]:
- """Poll the ``transform`` task logs until a record matching *match*
appears.
-
- Logs can lag behind the terminal task state, and earlier records (e.g.
the
- first transform line) arrive before the one under test, so returning
on any
- record would race. Keep polling until the target record shows up or the
- deadline passes. Returns the matching record (or ``None``) and the last
- batch of records seen for diagnostics.
- """
- deadline = time.monotonic() + _LOG_FETCH_TIMEOUT
- records: list[dict] = []
- while True:
- resp = self.airflow_client.get_task_logs(
- dag_id="java_annotation_example", run_id=run_id,
task_id="transform", try_number=try_number
- )
- records = [entry for entry in resp.get("content", []) if
isinstance(entry, dict)]
- record = next((r for r in records if match(r)), None)
- if record is not None or time.monotonic() > deadline:
- return record, records
- time.sleep(3)
-
def test_application_logs_preserve_their_level(self):
"""A Java task's SLF4J ``logger.info`` must reach the UI as INFO, not
ERROR.
@@ -280,7 +286,10 @@ class TestJavaSDKAnnotationExample:
)
# transform logs `logger.info("Got variable {}", variable)` -> "Got
variable 123".
- record, records = self._wait_for_transform_log_record(
+ record, records = _wait_for_task_log_record(
+ self.airflow_client,
+ "java_annotation_example",
+ "transform",
run_id,
transform_ti.get("try_number", 1),
lambda r: str(r.get("event", "")).startswith("Got variable"),
@@ -294,6 +303,79 @@ class TestJavaSDKAnnotationExample:
)
+class TestJavaSDKUninstantiableTask:
+ """Verify a task class the runner cannot instantiate fails with an
actionable log.
+
+ The deliberately broken task classes live in the java-test-bundle fixture
+ project (served on the dedicated "java-test" queue), not in the user-facing
+ example bundle.
+ """
+
+ airflow_client = AirflowClient()
+
+ @pytest.mark.parametrize(
+ ("task_id", "expected_task_class"),
+ [
+ (
+ "missing_no_arg_constructor",
+
"org.apache.airflow.e2e.TestBundleBuilder$MissingNoArgConstructor",
+ ),
+ (
+ "non_static_inner",
+ "org.apache.airflow.e2e.TestBundleBuilder$NonStaticInner",
+ ),
+ ],
+ )
+ def test_uninstantiable_task_fails_with_actionable_error(self, task_id:
str, expected_task_class: str):
+ """A task class the runner cannot instantiate fails with a clear log
message."""
+ resp = self.airflow_client.trigger_dag(
+ "java_uninstantiable",
+ json={"logical_date": datetime.now(timezone.utc).isoformat()},
+ )
+ run_id = resp["dag_run_id"]
+
+ dag_state = self.airflow_client.wait_for_dag_run(
+ dag_id="java_uninstantiable",
+ run_id=run_id,
+ timeout=_JAVA_TASK_TIMEOUT,
+ )
+
+ ti_resp =
self.airflow_client.get_task_instances(dag_id="java_uninstantiable",
run_id=run_id)
+ ti_map = {ti["task_id"]: ti for ti in ti_resp.get("task_instances",
[])}
+ ti = ti_map.get(task_id, {})
+
+ assert ti.get("state") == "failed", (
+ f"Java {task_id!r} task should fail cleanly.\n"
+ f" task state : {ti.get('state')!r}\n"
+ f" dag state : {dag_state!r}\n"
+ f" all tasks : { {k: v.get('state') for k, v in ti_map.items()}
}"
+ )
+
+ record, records = _wait_for_task_log_record(
+ self.airflow_client,
+ "java_uninstantiable",
+ task_id,
+ run_id,
+ ti.get("try_number", 1),
+ lambda r: str(r.get("event", "")).startswith("Cannot instantiate
task class"),
+ )
+ assert record is not None, (
+ f"{task_id!r} should emit a 'Cannot instantiate task class'
record; "
+ f"events seen: {[r.get('event') for r in records]}"
+ )
+ assert record.get("event") == (
+ "Cannot instantiate task class. "
+ "A task class must be public, concrete, and declare a public
no-argument constructor"
+ ), f"instantiation error should carry the full actionable message;
record: {record}"
+ assert str(record.get("level", "")).lower() == "error", (
+ f"instantiation error should be logged as ERROR, got
{record.get('level')!r}; record: {record}"
+ )
+ assert record.get("taskClass") == expected_task_class, (
+ f"instantiation error should name the offending class
{expected_task_class!r}, "
+ f"got {record.get('taskClass')!r}; record: {record}"
+ )
+
+
# Each Scala task spins up its own local SparkSession; allow generous time for
# three sequential JVM + Spark startups in a constrained CI container.
_SPARK_TASK_TIMEOUT = 1200
diff --git a/dev/breeze/doc/ci/04_selective_checks.md
b/dev/breeze/doc/ci/04_selective_checks.md
index 78848313242..be4fa4d4c82 100644
--- a/dev/breeze/doc/ci/04_selective_checks.md
+++ b/dev/breeze/doc/ci/04_selective_checks.md
@@ -441,6 +441,13 @@ together using `pytest-xdist` (pytest-xdist distributes
the tests among parallel
of affected providers (but not recursively - only direct dependencies are
added)
* if there are any changes to "common" provider code not belonging to any
provider (usually system tests
or tests), then tests for all Providers are run
+* `Java SDK E2E tests` (the `java_sdk` mode of the deployed-stack tests,
exposed as the
+ `run-java-sdk-e2e-tests` output) run when the Java SDK sources (`java-sdk/`,
excluding `.md`), the
+ Java test-fixture bundle (`airflow-e2e-tests/java-test-bundle/`), the Java
e2e suite or its Docker
+ files (`airflow-e2e-tests/tests/airflow_e2e_tests/java_sdk_tests/`,
+ `airflow-e2e-tests/docker/java.yml`,
`airflow-e2e-tests/docker/Dockerfile.java`), or the Java
+ coordinator (`task-sdk/src/airflow/sdk/coordinators/java/`,
`_subprocess.py`) change. Like the
+ other deployed e2e suites, enabling them forces `PROD Image building`.
* `OpenLineage E2E tests` (the `openlineage` mode of the deployed-stack tests
under
`airflow-e2e-tests/tests/airflow_e2e_tests/openlineage_tests`, exposed as the
`run-openlineage-e2e-tests` output) run when the `openlineage` or `common`
providers or the
diff --git a/dev/breeze/src/airflow_breeze/utils/selective_checks.py
b/dev/breeze/src/airflow_breeze/utils/selective_checks.py
index 02c2e4bb929..d831e569227 100644
--- a/dev/breeze/src/airflow_breeze/utils/selective_checks.py
+++ b/dev/breeze/src/airflow_breeze/utils/selective_checks.py
@@ -245,6 +245,7 @@ CI_FILE_GROUP_MATCHES: HashableDict[FileGroupForCi] =
HashableDict(
FileGroupForCi.JAVA_SDK_E2E_FILES: [
# `.md` excluded — doc-only edits do not affect the Gradle build.
r"^java-sdk/(?!.*\.md$).*",
+ r"^airflow-e2e-tests/java-test-bundle/.*",
r"^airflow-e2e-tests/tests/airflow_e2e_tests/java_sdk_tests/.*",
r"^airflow-e2e-tests/docker/java\.yml$",
r"^airflow-e2e-tests/docker/Dockerfile\.java$",
diff --git a/dev/breeze/tests/test_selective_checks.py
b/dev/breeze/tests/test_selective_checks.py
index eb1c32eac64..447db93babf 100644
--- a/dev/breeze/tests/test_selective_checks.py
+++ b/dev/breeze/tests/test_selective_checks.py
@@ -1539,6 +1539,15 @@ def assert_outputs_are_printed(expected_outputs:
dict[str, str], stderr: str):
},
id="Skip ts-sdk docs build for a ts-sdk README-only change",
),
+ pytest.param(
+
("airflow-e2e-tests/java-test-bundle/src/java/org/apache/airflow/e2e/TestBundleBuilder.java",),
+ {
+ "run-java-sdk-tests": "false",
+ "run-java-sdk-e2e-tests": "true",
+ "prod-image-build": "true",
+ },
+ id="Run java e2e tests when the java test-fixture bundle changes",
+ ),
pytest.param(
("task-sdk/src/airflow/sdk/coordinators/java/coordinator.py",),
{
diff --git
a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/Task.kt
b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/Task.kt
index bddc5256f55..9f1a7537046 100644
--- a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/Task.kt
+++ b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/Task.kt
@@ -28,6 +28,7 @@ import org.apache.airflow.sdk.execution.comm.RetryTask
import org.apache.airflow.sdk.execution.comm.StartupDetails
import org.apache.airflow.sdk.execution.comm.SucceedTask
import org.apache.airflow.sdk.execution.comm.TaskState
+import java.lang.reflect.InvocationTargetException
import java.time.OffsetDateTime
internal object TaskResult {
@@ -61,6 +62,8 @@ internal object TaskResult {
it.endDate = endDate
it.renderedMapIndex = renderedMapIndex
}
+
+ fun failure(shouldRetry: Boolean) = if (shouldRetry) retry() else
of(TaskState.State.FAILED)
}
internal object TaskRunner {
@@ -74,19 +77,39 @@ internal object TaskRunner {
val definition =
bundle.dags[request.ti.dagId]?.tasks[request.ti.taskId]?.definition
?: return TaskResult.of(TaskState.State.REMOVED)
+ val instance =
+ try {
+ definition.getDeclaredConstructor().newInstance()
+ } catch (e: InvocationTargetException) {
+ val cause = e.cause ?: e
+ logger.error(
+ "Task class constructor threw an exception",
+ mapOf("ti" to request.ti, "taskClass" to definition.name, "error" to
cause, "trace" to cause.stackTraceToString()),
+ )
+ // Retrying cannot help: instantiation fails the same way on every try.
+ return TaskResult.failure(shouldRetry = false)
+ } catch (e: ReflectiveOperationException) {
+ logger.error(
+ "Cannot instantiate task class. A task class must be public,
concrete, and declare a public no-argument constructor",
+ mapOf("ti" to request.ti, "taskClass" to definition.name, "error" to
e, "trace" to e.stackTraceToString()),
+ )
+ return TaskResult.failure(shouldRetry = false)
+ } catch (e: Throwable) {
+ // A valid class can still fail to initialize (static initializer,
linkage); a fresh JVM may succeed.
+ logger.error(
+ "Error initializing task class",
+ mapOf("ti" to request.ti, "taskClass" to definition.name, "error" to
e, "trace" to e.stackTraceToString()),
+ )
+ return TaskResult.failure(request.tiContext.shouldRetry)
+ }
return try {
-
definition.getDeclaredConstructor().newInstance().execute(Context.from(request),
client)
+ instance.execute(Context.from(request), client)
TaskResult.success()
} catch (e: CancellationException) {
throw e // Let coroutine cancellation propagate so the task coroutine
unwinds.
} catch (e: Throwable) {
logger.error("Error executing task", mapOf("ti" to request.ti, "error"
to e, "trace" to e.stackTraceToString()))
- e.printStackTrace()
- if (request.tiContext.shouldRetry) {
- TaskResult.retry()
- } else {
- TaskResult.of(TaskState.State.FAILED)
- }
+ TaskResult.failure(request.tiContext.shouldRetry)
}
}
}
diff --git
a/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/execution/TaskTest.kt
b/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/execution/TaskTest.kt
index 5d303a3dd81..1b1fd288ed7 100644
--- a/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/execution/TaskTest.kt
+++ b/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/execution/TaskTest.kt
@@ -36,6 +36,8 @@ import org.apache.airflow.sdk.execution.comm.TaskState
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
+import java.io.ByteArrayOutputStream
+import java.io.PrintStream
import java.time.OffsetDateTime
import java.util.UUID
@@ -85,6 +87,88 @@ class TaskTest {
Assertions.assertEquals(TaskState.State.FAILED, (result as
TaskState).state)
}
+ @Test
+ @DisplayName("Should not duplicate the stack trace to stderr when task
fails")
+ fun shouldNotDuplicateStackTraceToStderrWhenTaskFails() {
+ val captured = ByteArrayOutputStream()
+ val original = System.err
+ System.setErr(PrintStream(captured))
+ try {
+ runTask(bundleWith("failing", FailingTask::class.java),
startupDetails(taskId = "failing"), noOpClient())
+ } finally {
+ System.setErr(original)
+ }
+
+ Assertions.assertEquals("", captured.toString())
+ }
+
+ @Test
+ @DisplayName("Should return failed and log an actionable message when task
class has no public no-argument constructor")
+ fun shouldReturnFailedWhenTaskClassHasNoNoArgConstructor() {
+ LogSender.messages.clear()
+ val result =
+ runTask(bundleWith("uninstantiable",
NoDefaultConstructorTask::class.java), startupDetails(taskId =
"uninstantiable"), noOpClient())
+
+ Assertions.assertInstanceOf(TaskState::class.java, result)
+ Assertions.assertEquals(TaskState.State.FAILED, (result as
TaskState).state)
+ val message = LogSender.messages.single { it.level == Level.ERROR }
+ Assertions.assertTrue(message.event.contains("public no-argument
constructor")) { "unexpected event: ${message.event}" }
+ Assertions.assertEquals(NoDefaultConstructorTask::class.java.name,
message.arguments["taskClass"])
+ }
+
+ @Test
+ @DisplayName("Should return failed and log the constructor failure when task
class constructor throws")
+ fun shouldReturnFailedWhenTaskClassConstructorThrows() {
+ LogSender.messages.clear()
+ val result =
+ runTask(bundleWith("throwing", ThrowingConstructorTask::class.java),
startupDetails(taskId = "throwing"), noOpClient())
+
+ Assertions.assertInstanceOf(TaskState::class.java, result)
+ Assertions.assertEquals(TaskState.State.FAILED, (result as
TaskState).state)
+ val message = LogSender.messages.single { it.level == Level.ERROR }
+ Assertions.assertEquals("Task class constructor threw an exception",
message.event)
+ Assertions.assertEquals(ThrowingConstructorTask::class.java.name,
message.arguments["taskClass"])
+ Assertions.assertInstanceOf(IllegalStateException::class.java,
message.arguments["error"])
+ Assertions.assertEquals("constructor boom", (message.arguments["error"] as
Throwable).message)
+ }
+
+ @Test
+ @DisplayName("Should return failed and log an initialization failure when
the task class static initializer throws")
+ fun shouldReturnFailedWhenTaskClassStaticInitializerThrows() {
+ LogSender.messages.clear()
+ val result =
+ runTask(bundleWith("static_init", StaticInitFailureTask::class.java),
startupDetails(taskId = "static_init"), noOpClient())
+
+ Assertions.assertInstanceOf(TaskState::class.java, result)
+ Assertions.assertEquals(TaskState.State.FAILED, (result as
TaskState).state)
+ val message = LogSender.messages.single { it.level == Level.ERROR }
+ Assertions.assertEquals("Error initializing task class", message.event)
+ Assertions.assertEquals(StaticInitFailureTask::class.java.name,
message.arguments["taskClass"])
+ }
+
+ @Test
+ @DisplayName("Should return retry when the task class fails to initialize
and should_retry is true")
+ fun shouldReturnRetryWhenTaskClassFailsToInitializeAndShouldRetryIsTrue() {
+ val details = startupDetails(taskId = "static_init")
+ details.tiContext?.shouldRetry = true
+ val result = runTask(bundleWith("static_init",
StaticInitFailureTask::class.java), details, noOpClient())
+
+ Assertions.assertInstanceOf(RetryTask::class.java, result)
+ }
+
+ @Test
+ @DisplayName("Should return failed and never retry when task class cannot be
instantiated, even if should_retry is true")
+ fun
shouldReturnFailedEvenIfShouldRetryIsTrueWhenTaskClassCannotBeInstantiated() {
+ for (taskClass in listOf(NoDefaultConstructorTask::class.java,
ThrowingConstructorTask::class.java)) {
+ val details = startupDetails(taskId = "uninstantiable")
+ details.tiContext?.shouldRetry = true
+ val result = runTask(bundleWith("uninstantiable", taskClass), details,
noOpClient())
+
+ Assertions.assertInstanceOf(TaskState::class.java, result) { "unexpected
result for ${taskClass.simpleName}: $result" }
+ Assertions.assertEquals(TaskState.State.FAILED, (result as
TaskState).state)
+ }
+ }
+
private fun bundleWith(
taskId: String,
taskClass: Class<out Task>,
@@ -171,4 +255,39 @@ class TaskTest {
client: Client,
): Unit = throw NoClassDefFoundError("simulated")
}
+
+ class ThrowingConstructorTask : Task {
+ init {
+ throw IllegalStateException("constructor boom")
+ }
+
+ override fun execute(
+ context: Context,
+ client: Client,
+ ) {
+ }
+ }
+
+ class StaticInitFailureTask : Task {
+ companion object {
+ init {
+ throw IllegalStateException("static init boom")
+ }
+ }
+
+ override fun execute(
+ context: Context,
+ client: Client,
+ ) {
+ }
+ }
+
+ class NoDefaultConstructorTask(
+ unused: String,
+ ) : Task {
+ override fun execute(
+ context: Context,
+ client: Client,
+ ): Unit = throw IllegalStateException("should not be reachable")
+ }
}