Skip to content

Commit 77efec6

Browse files
committed
Java SDK: Draw a Java-authored Dag's edges with before and after
A Dag authored in Java had no way to say what runs after what. Without a Python Dag file to hold the graph, every task was a root, so a Dag that Java owns end to end could not be expressed at all. The verbs are before and after, matching the TypeScript SDK, and leaving then free for the conditional control flow that reads as a then-branch.
1 parent bf6a44c commit 77efec6

9 files changed

Lines changed: 535 additions & 11 deletions

File tree

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

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -348,7 +348,8 @@ Interface-based API
348348
~~~~~~~~~~~~~~~~~~~
349349

350350
Implement the ``Task`` interface directly for full control over how tasks are registered and how XComs are
351-
read. Each task is registered as a ``TaskDef`` on a ``DagDef``.
351+
read. Each task is registered as a ``TaskDef`` on a ``DagDef``; ``TaskDef`` also carries
352+
``dependsOn(...)`` for declaring edges between task definitions.
352353

353354
The runner creates a fresh instance of the task class through reflection for every task-instance run,
354355
which puts four constraints on the class:
@@ -525,6 +526,41 @@ An ``InputTask`` whose type argument is not a concrete ``TaskInput`` fails when
525526
rather than mid-run. Plain ``Task`` remains the right interface for a task the Dag file
526527
calls with no arguments.
527528

529+
.. _java-sdk/native-dags:
530+
531+
Native Java Dags
532+
----------------
533+
534+
A Dag can also be authored entirely in Java, with no Python stub file: the ``DagDef`` and
535+
``TaskDef`` objects hold the tasks, and Java declares the graph.
536+
537+
Building the Dag in Java
538+
~~~~~~~~~~~~~~~~~~~~~~~~
539+
540+
``dag.task(...)`` registers a task as it creates it and hands back a handle, so there is no second
541+
``addTask`` call to forget. ``before`` and ``after`` draw every edge on this surface — Python's
542+
``a >> b`` and ``b << a`` — and the task body moves the data itself, by reading the upstream's XCom
543+
through ``Client``:
544+
545+
.. code-block:: java
546+
547+
var dag = new DagDef("java_etl");
548+
549+
var extract = dag.task("extract", Extract.class);
550+
var transform = dag.task("transform", Transform.class);
551+
var load = dag.task("load", Load.class);
552+
553+
transform.after(extract).before(load);
554+
555+
Both are variadic, so ``a.before(b, c)`` fans out and ``d.after(b, c)`` fans in, and both return
556+
their own receiver, so a chain reads from one task outwards. ``Deps.Flow.of(a, b).before(c, d)``
557+
draws every edge between two sets in one call. ``TaskDef.dependsOn(...)`` declares the same edges
558+
from the definitions rather than the handles, for code that builds ``TaskDef`` objects up front.
559+
560+
Edges are checked when the Dag is registered with a ``Bundle``: an upstream that belongs to another
561+
Dag, or to no Dag, and a cycle anywhere in the graph both fail there rather than at the first task
562+
run.
563+
528564
.. _java-sdk/logging:
529565

530566
Logging

‎java-sdk/example/src/java/org/apache/airflow/example/ExampleBundleBuilder.java‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ public static Bundle build() {
2626
return new Bundle()
2727
.register(InterfaceExampleBuilder.build())
2828
.register(AnnotationExample.class)
29-
.register(XComCastingExample.class);
29+
.register(XComCastingExample.class)
30+
.register(org.apache.airflow.example.nativedag.InterfaceExample.build());
3031
}
3132

3233
public static void main(String[] args) {
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
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+
// "native" is a Java keyword, so the native-Dag examples live in "nativedag".
21+
package org.apache.airflow.example.nativedag;
22+
23+
import static java.lang.System.Logger.Level.INFO;
24+
25+
import org.apache.airflow.sdk.*;
26+
27+
// A Dag defined entirely in Java, interface-style: no Python stub file
28+
// describes it. dag.task registers a task as it creates it and hands back the
29+
// handle, and `before`/`after` wire the graph -- Java's spelling of `>>` and `<<`.
30+
public class InterfaceExample {
31+
private static final System.Logger log = System.getLogger(InterfaceExample.class.getName());
32+
33+
public static class Extract implements Task {
34+
@Override
35+
public void execute(Context context, Client client) {
36+
log.log(INFO, "Extracting a value");
37+
client.setXCom(42L);
38+
}
39+
}
40+
41+
public static class Transform implements Task {
42+
@Override
43+
public void execute(Context context, Client client) {
44+
var extracted = ((Number) client.getXCom("extract")).longValue();
45+
log.log(INFO, "Transforming {0}", extracted);
46+
client.setXCom(extracted * 2);
47+
}
48+
}
49+
50+
public static class Load implements Task {
51+
@Override
52+
public void execute(Context context, Client client) {
53+
var transformed = client.getXCom("transform");
54+
log.log(INFO, "Loaded {0}", transformed);
55+
}
56+
}
57+
58+
public static DagDef build() {
59+
var dag = new DagDef("java_native_interface_example");
60+
61+
var extract = dag.task("extract", Extract.class);
62+
var transform = dag.task("transform", Transform.class);
63+
var load = dag.task("load", Load.class);
64+
65+
transform.after(extract).before(load);
66+
return dag;
67+
}
68+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
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.sdk
21+
22+
/**
23+
* A value a task can be given, of which [TaskRef] — the output of an upstream
24+
* task — is the only form so far.
25+
*
26+
* @param T Type of the value.
27+
*/
28+
sealed class Arg<T>
29+
30+
/**
31+
* The output of a registered task, and the task's place in the flow.
32+
*
33+
* [Deps.Flow.before] and [Deps.Flow.after] wire an ordering-only edge from
34+
* this task, where nothing flows but the sequence.
35+
*
36+
* @param T Return type of the task this handle refers to.
37+
*/
38+
class TaskRef<T> internal constructor(
39+
internal val def: TaskDef,
40+
) : Arg<T>(),
41+
Deps.Flow {
42+
override fun nodes(): List<TaskDef> = listOf(def)
43+
44+
override fun before(vararg next: Deps.Flow): TaskRef<T> {
45+
super<Deps.Flow>.before(*next)
46+
return this
47+
}
48+
49+
override fun after(vararg previous: Deps.Flow): TaskRef<T> {
50+
super<Deps.Flow>.after(*previous)
51+
return this
52+
}
53+
}

‎java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/Bundle.kt‎

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ import org.apache.airflow.sdk.internal.registrarName
2828
* [Server.serve] to start accepting task-execution requests.
2929
*
3030
* @property dags All registered Dags keyed by [DagDef.id].
31-
* @throws IllegalArgumentException if any two Dags share the same ID.
31+
* @throws IllegalArgumentException if any two Dags share the same ID, if a
32+
* task depends on an upstream that is not registered in its own Dag, or if
33+
* the dependencies of a Dag contain a cycle.
3234
*/
3335
class Bundle(
3436
dags: Iterable<DagDef>,
@@ -45,10 +47,24 @@ class Bundle(
4547
/**
4648
* Registers a Dag.
4749
*
50+
* The Dag is checked as it is registered, so a bad edge fails here rather
51+
* than at the first task run.
52+
*
4853
* @return This bundle, for chaining.
49-
* @throws IllegalArgumentException if another Dag shares its ID.
54+
* @throws IllegalArgumentException if another Dag shares its ID, a task
55+
* depends on an upstream not registered in the same Dag, or the
56+
* dependencies contain a cycle.
5057
*/
5158
fun register(dag: DagDef): Bundle {
59+
for ((taskId, def) in dag.tasks) {
60+
for (upstream in def.upstreams) {
61+
require(dag.tasks[upstream.id] === upstream) {
62+
"Task '$taskId' in Dag '${dag.id}' depends on task '${upstream.id}' " +
63+
"that is not registered in the same Dag"
64+
}
65+
}
66+
}
67+
checkNoCycle(dag)
5268
require(dags.putIfAbsent(dag.id, dag) == null) {
5369
"Dags in bundle have duplicate ID: ${dag.id}"
5470
}
@@ -85,8 +101,8 @@ class Bundle(
85101
* Registers one task implementation against a Dag the Python file owns, for
86102
* a task with no annotation to read the ids from.
87103
*
88-
* The Dag is created on first use: a stub-backed Dag exists only so the
89-
* runtime can find the task, and its graph lives in the Python Dag file.
104+
* The Dag is created on first use and holds only the tasks registered
105+
* here; its graph lives in the Python Dag file.
90106
*
91107
* @param dagId Dag ID as declared in the Python Dag file.
92108
* @param taskId Task ID as declared by the `@task.stub` function.
@@ -103,6 +119,23 @@ class Bundle(
103119
}
104120
}
105121

122+
// TaskDef.dependsOn can express a cycle, so reject one at registration time.
123+
private fun checkNoCycle(dag: DagDef) {
124+
val visiting = mutableSetOf<String>()
125+
val done = mutableSetOf<String>()
126+
127+
fun visit(def: TaskDef) {
128+
if (def.id in done) return
129+
require(visiting.add(def.id)) {
130+
"Task dependencies in Dag '${dag.id}' contain a cycle involving task '${def.id}'"
131+
}
132+
def.upstreams.forEach(::visit)
133+
visiting -= def.id
134+
done += def.id
135+
}
136+
dag.tasks.values.forEach(::visit)
137+
}
138+
106139
/**
107140
* Entry point for declaring the [DagDef]s that this bundle contains.
108141
*

‎java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/DagDef.kt‎

Lines changed: 71 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,9 @@ import kotlin.Throws
3232
* class directly if you need to do low-level plumbing:
3333
*
3434
* ```java
35-
* var dag = new DagDef("java_etl")
36-
* .addTask("extract", Extract.class)
37-
* .addTask("load", Load.class);
35+
* var dag = new DagDef("java_etl");
36+
* var extract = dag.task("extract", Extract.class);
37+
* extract.before(dag.task("load", Load.class));
3838
* ```
3939
*
4040
* @param id Dag identifier. Must contain only ASCII alphanumeric characters,
@@ -62,12 +62,41 @@ class DagDef(
6262
definition: Class<out Task>,
6363
): DagDef = addTask(TaskDef(id, definition))
6464

65+
/**
66+
* Creates a task, registers it, and hands back its handle — so there is no
67+
* second `addTask` call to forget, and the handle is ready to wire edges
68+
* with [Deps.Flow.before].
69+
*
70+
* ```java
71+
* var extract = dag.task("extract", Extract.class);
72+
* var load = dag.task("load", Load.class);
73+
* extract.before(load);
74+
* ```
75+
*
76+
* @param id Task identifier, unique within this Dag.
77+
* @param definition Class that implements [Task]. Must have a public no-arg
78+
* constructor.
79+
* @return The handle representing this task.
80+
* @throws IllegalArgumentException if a task with the same ID is already
81+
* registered.
82+
*/
83+
fun <T> task(
84+
id: String,
85+
definition: Class<out Task>,
86+
): TaskRef<T> {
87+
val def = TaskDef(id, definition)
88+
addTask(def)
89+
return TaskRef(def)
90+
}
91+
6592
/**
6693
* Registers a task with this Dag.
6794
*
6895
* A [TaskDef] belongs to at most one [DagDef]; registering the same instance
6996
* with a second Dag, or twice with the same one, fails. Task IDs must be
70-
* unique within a Dag.
97+
* unique within a Dag. Upstream tasks referenced via [TaskDef.dependsOn]
98+
* must be registered with the same Dag by the time it is added to a
99+
* [Bundle].
71100
*
72101
* @param task Task definition to register.
73102
* @return This Dag, for chaining.
@@ -84,13 +113,35 @@ class DagDef(
84113
task.owner = this
85114
return this
86115
}
116+
117+
/**
118+
* Registers a task with this Dag, adding [upstreams] as its dependencies.
119+
*
120+
* Equivalent to `addTask(task.dependsOn(...))`; see [addTask] and
121+
* [TaskDef.dependsOn].
122+
*
123+
* @param task Task definition to register.
124+
* @param upstreams Tasks that [task] depends on (its upstream tasks).
125+
* @return This Dag, for chaining.
126+
* @throws IllegalArgumentException if the task already belongs to a Dag or a
127+
* task with the same ID is already registered.
128+
*/
129+
fun addTask(
130+
task: TaskDef,
131+
upstreams: List<TaskDef>,
132+
): DagDef {
133+
upstreams.forEach { task.dependsOn(it) }
134+
return addTask(task)
135+
}
87136
}
88137

89138
/**
90-
* One task definition: its ID and the class that implements it.
139+
* One task definition: its ID, the class that implements it, and its upstream
140+
* dependencies.
91141
*
92142
* ```java
93143
* var extract = new TaskDef("extract", Extract.class);
144+
* var load = new TaskDef("load", Load.class).dependsOn(extract);
94145
* ```
95146
*
96147
* @param id Task identifier, unique within a [DagDef].
@@ -110,7 +161,22 @@ class TaskDef(
110161
validateTaskInput(definition)
111162
}
112163

164+
internal val upstreams = linkedSetOf<TaskDef>()
113165
internal var owner: DagDef? = null
166+
167+
/**
168+
* Declares that this task runs after [upstreams].
169+
*
170+
* Referenced tasks must be registered with the same [DagDef] as this task by
171+
* the time it is added to a [Bundle].
172+
*
173+
* @param upstreams Tasks this task depends on.
174+
* @return This task definition, for chaining.
175+
*/
176+
fun dependsOn(vararg upstreams: TaskDef): TaskDef {
177+
this.upstreams += upstreams
178+
return this
179+
}
114180
}
115181

116182
/**

0 commit comments

Comments
 (0)