Skip to content

Commit 15803ca

Browse files
committed
Java SDK: Clearer error when a task class cannot be instantiated
Signed-off-by: PoAn Yang <payang@apache.org>
1 parent ef1dc74 commit 15803ca

16 files changed

Lines changed: 543 additions & 51 deletions

File tree

airflow-core/docs/authoring-and-scheduling/language-sdks/java.rst

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,17 @@ Interface-based API
205205
Implement the ``Task`` interface directly for full control over how tasks are registered and how XComs are
206206
read. Each task is registered as a ``TaskDef`` on a ``DagDef``.
207207

208+
The runner creates a fresh instance of the task class through reflection for every task-instance run,
209+
which puts four constraints on the class:
210+
211+
* The task class itself must be ``public``.
212+
* It must be concrete: not abstract and not an interface.
213+
* It must declare a public no-argument constructor.
214+
* If nested inside another class, it must be a ``static`` nested class.
215+
216+
A class that violates any of these fails at runtime with a ``Cannot instantiate task class`` error in the
217+
task log.
218+
208219
.. code-block:: java
209220
210221
import org.apache.airflow.sdk.*;
@@ -218,11 +229,21 @@ read. Each task is registered as a ``TaskDef`` on a ``DagDef``.
218229
}
219230
}
220231
221-
Register tasks manually in a ``BundleBuilder``:
232+
Register tasks manually in a ``BundleBuilder``. A task class can be top-level like ``FetchTask``, or
233+
nested ``static`` class like ``ProcessTask``:
222234

223235
.. code-block:: java
224236
225237
public class MyBundle implements BundleBuilder {
238+
public static class ProcessTask implements Task {
239+
@Override
240+
public void execute(Context context, Client client) throws Exception {
241+
var fetched = (String) client.getXCom("fetch");
242+
// implement task logic
243+
client.setXCom(fetched);
244+
}
245+
}
246+
226247
@Override
227248
public Iterable<DagDef> getDags() {
228249
var dag = new DagDef("my_dag")

airflow-e2e-tests/docker/java.yml

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,17 @@
1919
#
2020
# Replaces the stock airflow-worker image with one that has a JRE installed
2121
# (built by conftest._setup_java_sdk_integration via Dockerfile.java), mounts
22-
# the pre-built bundle JARs (the Java example under /opt/airflow/java-jars and
23-
# the Scala Spark example under /opt/airflow/scala-jars), and configures the
24-
# worker to consume the "java" and "scala" Celery queues where @task.stub tasks
25-
# are routed.
22+
# the pre-built bundle JARs (the Java example under /opt/airflow/java-jars, the
23+
# Scala Spark example under /opt/airflow/scala-jars, and the runner-behaviour
24+
# test fixtures under /opt/airflow/java-test-jars), and configures the worker to
25+
# consume the "java", "scala", and "java-test" Celery queues where @task.stub
26+
# tasks are routed.
2627
---
2728
services:
2829
airflow-worker:
2930
image: airflow-java-worker
3031
volumes:
3132
- ./java-jars:/opt/airflow/java-jars:ro
3233
- ./scala-jars:/opt/airflow/scala-jars:ro
33-
command: celery worker -q java,scala,default
34+
- ./java-test-jars:/opt/airflow/java-test-jars:ro
35+
command: celery worker -q java,scala,java-test,default
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
.gradle
2+
build/
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
plugins {
21+
id("org.apache.airflow.sdk") version "${projectVersion}"
22+
}
23+
24+
repositories {
25+
mavenLocal()
26+
mavenCentral()
27+
}
28+
29+
dependencies {
30+
implementation("org.apache.airflow:airflow-sdk:${projectVersion}")
31+
implementation("org.apache.airflow:airflow-sdk-jpl:${projectVersion}")
32+
}
33+
34+
java {
35+
toolchain {
36+
languageVersion.set(JavaLanguageVersion.of(11))
37+
}
38+
sourceCompatibility = JavaVersion.VERSION_11
39+
}
40+
41+
sourceSets {
42+
main {
43+
java.srcDir("src/java")
44+
}
45+
}
46+
47+
airflowBundle {
48+
mainClass = "org.apache.airflow.e2e.TestBundleBuilder"
49+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
18+
org.gradle.configuration-cache=true
19+
20+
projectVersion=1.0.0-SNAPSHOT
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
// Route the plugin lookup to the SDK build published to the local Maven
21+
// repository by conftest._setup_java_sdk_integration.
22+
pluginManagement {
23+
repositories {
24+
mavenLocal()
25+
gradlePluginPortal()
26+
}
27+
}
28+
29+
plugins {
30+
id("org.gradle.toolchains.foojay-resolver-convention") version "0.10.0"
31+
}
32+
33+
rootProject.name = "airflow-e2e-java-test-bundle"
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
package org.apache.airflow.e2e;
21+
22+
import java.util.List;
23+
import org.apache.airflow.sdk.*;
24+
import org.jetbrains.annotations.NotNull;
25+
26+
/**
27+
* Bundle of deliberately broken task classes for the runner-behaviour E2E tests.
28+
*/
29+
public class TestBundleBuilder implements BundleBuilder {
30+
public static class MissingNoArgConstructor implements Task {
31+
public MissingNoArgConstructor(String unused) {}
32+
33+
public void execute(@NotNull Context context, Client client) {
34+
throw new IllegalStateException("should not be reachable");
35+
}
36+
}
37+
38+
/**
39+
* A non-static nested class declares no constructor of its own, but the implicit one
40+
* takes the enclosing instance, so the runner's lookup for a no-argument constructor
41+
* fails.
42+
*/
43+
public class NonStaticInner implements Task {
44+
public void execute(@NotNull Context context, Client client) {
45+
throw new IllegalStateException("should not be reachable");
46+
}
47+
}
48+
49+
@NotNull
50+
@Override
51+
public Iterable<DagDef> getDags() {
52+
var dag = new DagDef("java_uninstantiable");
53+
dag.addTask("missing_no_arg_constructor", MissingNoArgConstructor.class);
54+
dag.addTask("non_static_inner", NonStaticInner.class);
55+
return List.of(dag);
56+
}
57+
58+
public static void main(String[] args) {
59+
var bundle = new TestBundleBuilder().build();
60+
Server.create(args).serve(bundle);
61+
}
62+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
"""Stub Dags for the Java runner-behaviour E2E fixtures in the java-test-bundle."""
18+
19+
from __future__ import annotations
20+
21+
from airflow.sdk import dag, task
22+
23+
24+
@task.stub(queue="java-test")
25+
def missing_no_arg_constructor(): ...
26+
27+
28+
@task.stub(queue="java-test")
29+
def non_static_inner(): ...
30+
31+
32+
@dag(dag_id="java_uninstantiable")
33+
def java_uninstantiable():
34+
missing_no_arg_constructor()
35+
non_static_inner()
36+
37+
38+
java_uninstantiable()

airflow-e2e-tests/tests/airflow_e2e_tests/conftest.py

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@
5151
JAVA_SDK_EXAMPLE_LIBS_PATH,
5252
JAVA_SDK_MAVEN_CACHE_PATH,
5353
JAVA_SDK_ROOT_PATH,
54+
JAVA_TEST_BUNDLE_DAGS_PATH,
55+
JAVA_TEST_BUNDLE_LIBS_PATH,
56+
JAVA_TEST_BUNDLE_ROOT_PATH,
5457
KAFKA_DIR_PATH,
5558
LANG_SDK_NATIVE_TOOLCHAIN,
5659
LOCALSTACK_PATH,
@@ -376,25 +379,30 @@ def _setup_java_sdk_integration(dot_env_file, tmp_dir):
376379
console.print("[yellow]Publishing Java SDK artifacts to local Maven repository...")
377380
_run_java_sdk_gradle(JAVA_SDK_ROOT_PATH, "publishToMavenLocal", "-PskipSigning=true", native=native)
378381

379-
# The example and scala_spark_example are independent Gradle builds that both
380-
# consume the SDK artifact published above, so build them concurrently. Sharing
381-
# a writable Gradle user home between concurrent builds is safe because each
382-
# build can ping the other's lock-owner port over one shared loopback - the
383-
# host's own in native mode, --network=host in the container path (see the
384-
# helper's docstring); publishToMavenLocal has already unpacked the shared
385-
# wrapper distribution, so neither build races to fetch it.
382+
# The example, scala_spark_example, and java-test-bundle are independent
383+
# Gradle builds that all consume the SDK artifact published above, so build
384+
# them concurrently. Sharing a writable Gradle user home between concurrent
385+
# builds is safe because each build can ping the other's lock-owner port over
386+
# one shared loopback - the host's own in native mode, --network=host in the
387+
# container path (see the helper's docstring); publishToMavenLocal has
388+
# already unpacked the shared wrapper distribution, so no build races to
389+
# fetch it.
386390
#
387391
# The Gradle `bundle` task is a Copy that never prunes its destination, so
388392
# JARs from an earlier build linger. A stale dependency JAR with its own
389393
# Main-Class would make JavaCoordinator's Main-Class discovery ambiguous, so
390394
# start each bundle from an empty directory.
391395
rmtree(JAVA_SDK_EXAMPLE_LIBS_PATH, ignore_errors=True)
392396
rmtree(SCALA_SPARK_EXAMPLE_LIBS_PATH, ignore_errors=True)
397+
rmtree(JAVA_TEST_BUNDLE_LIBS_PATH, ignore_errors=True)
393398
toolchain = "host toolchain" if native else "eclipse-temurin:17-jdk"
394-
console.print(f"[yellow]Building Java SDK and Scala Spark example bundles concurrently ({toolchain})...")
399+
console.print(
400+
f"[yellow]Building Java SDK, Scala Spark, and test-fixture bundles concurrently ({toolchain})..."
401+
)
395402
example_bundle_workdirs = [
396403
JAVA_SDK_ROOT_PATH / "example",
397404
JAVA_SDK_ROOT_PATH / "scala_spark_example",
405+
JAVA_TEST_BUNDLE_ROOT_PATH,
398406
]
399407
with ThreadPoolExecutor(max_workers=len(example_bundle_workdirs)) as pool:
400408
bundle_builds = [
@@ -411,19 +419,21 @@ def _setup_java_sdk_integration(dot_env_file, tmp_dir):
411419
# expose them to the worker, and each JavaCoordinator globs its own dir.
412420
copytree(JAVA_SDK_EXAMPLE_LIBS_PATH, tmp_dir / "java-jars")
413421
copytree(SCALA_SPARK_EXAMPLE_LIBS_PATH, tmp_dir / "scala-jars")
422+
copytree(JAVA_TEST_BUNDLE_LIBS_PATH, tmp_dir / "java-test-jars")
414423

415424
# Copy the Java SDK example Dag files so Airflow can discover them.
416425
copyfile(JAVA_SDK_EXAMPLE_DAGS_PATH / "java_examples.py", tmp_dir / "dags" / "java_examples.py")
417426
copyfile(
418427
SCALA_SPARK_EXAMPLE_DAGS_PATH / "scala_spark_examples.py",
419428
tmp_dir / "dags" / "scala_spark_examples.py",
420429
)
430+
copyfile(JAVA_TEST_BUNDLE_DAGS_PATH / "java_test_dags.py", tmp_dir / "dags" / "java_test_dags.py")
421431

422432
# Keep the bundle JARs out of the build context: Dockerfile.java only adds a
423433
# JRE and copies nothing from the context, so without this docker build would
424434
# tar and stream the bundles (hundreds of MB of Spark JARs) to the daemon for
425435
# nothing. The JARs reach the worker via the compose bind-mounts, not the image.
426-
(tmp_dir / ".dockerignore").write_text("java-jars/\nscala-jars/\n")
436+
(tmp_dir / ".dockerignore").write_text("java-jars/\nscala-jars/\njava-test-jars/\n")
427437

428438
# Build a local Docker image that extends DOCKER_IMAGE with a JRE.
429439
# We do this explicitly so testcontainers' DockerCompose.start() does not
@@ -445,10 +455,11 @@ def _setup_java_sdk_integration(dot_env_file, tmp_dir):
445455
check=True,
446456
)
447457

448-
# Two JavaCoordinators on the same worker image, one bundle per queue. The
449-
# scala-jdk entry pins main_class (Spark's large classpath makes Main-Class
450-
# discovery ambiguous) and carries Spark's Java 17 module openings, a small
451-
# driver heap, and a longer startup timeout for its large dependency classpath.
458+
# One JavaCoordinator per queue on the same worker image, each serving its
459+
# own bundle. The scala-jdk entry pins main_class (Spark's large classpath
460+
# makes Main-Class discovery ambiguous) and carries Spark's Java 17 module
461+
# openings, a small driver heap, and a longer startup timeout for its large
462+
# dependency classpath.
452463
coordinator_config = json.dumps(
453464
{
454465
"java-jdk": {
@@ -464,9 +475,15 @@ def _setup_java_sdk_integration(dot_env_file, tmp_dir):
464475
"task_startup_timeout": 60.0,
465476
},
466477
},
478+
"java-test-jdk": {
479+
"classpath": "airflow.sdk.coordinators.java.JavaCoordinator",
480+
"kwargs": {"jars_root": ["/opt/airflow/java-test-jars"]},
481+
},
467482
}
468483
)
469-
queue_to_coordinator = json.dumps({"java": "java-jdk", "scala": "scala-jdk"})
484+
queue_to_coordinator = json.dumps(
485+
{"java": "java-jdk", "scala": "scala-jdk", "java-test": "java-test-jdk"}
486+
)
470487

471488
# Connection expected by the Java example bundle tasks. The JSON form
472489
# covers all connection fields, in particular the port: wire integers

airflow-e2e-tests/tests/airflow_e2e_tests/constants.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,13 @@
7777
)
7878
SCALA_SPARK_EXAMPLE_LIBS_PATH = JAVA_SDK_ROOT_PATH / "scala_spark_example" / "build" / "bundle"
7979

80+
# Java test-fixture bundle paths (deliberately broken task classes for the
81+
# runner-behaviour E2E tests; a separate bundle with its own coordinator/queue
82+
# so they stay out of the user-facing example).
83+
JAVA_TEST_BUNDLE_ROOT_PATH = AIRFLOW_ROOT_PATH / "airflow-e2e-tests" / "java-test-bundle"
84+
JAVA_TEST_BUNDLE_DAGS_PATH = JAVA_TEST_BUNDLE_ROOT_PATH / "src" / "resources" / "dags"
85+
JAVA_TEST_BUNDLE_LIBS_PATH = JAVA_TEST_BUNDLE_ROOT_PATH / "build" / "bundle"
86+
8087
# Go SDK E2E test paths
8188
GO_SDK_ROOT_PATH = AIRFLOW_ROOT_PATH / "go-sdk"
8289
GO_SDK_DAGS_PATH = GO_SDK_ROOT_PATH / "dags"

0 commit comments

Comments
 (0)