Skip to content

Commit 68fd401

Browse files
committed
Java SDK: Author complete Dags in Java without a Python stub file
Until now the Java SDK could only supply task bodies: a Python @task.stub Dag had to own the schedule, every task option, and the graph. That splits one pipeline across two languages and two repositories for no reason other than a missing authoring surface, and it left the Java-side model with nothing to describe -- no edges, no configuration -- so there was nothing a native Java Dag could be built from. Java annotations cannot change call semantics the way Python decorators do, so the graph is declared against a compile-time-generated twin class (`<Class>Ref`): calling a twin registers the task and passing one twin's handle into another feeds the upstream's output into the downstream's parameter, making the call graph the task graph the way Python TaskFlow does -- but type-checked by javac through the In/TaskRef generics. Keeping the wiring calls the only way to express an edge means there is one graph story to learn instead of two, and the method stays optional so stub-backed classes are unchanged: their graph still lives in the Python Dag file, and runtime arg bindings continue to win over anything Java declares, because for a stub task the Python call site is the graph the scheduler ordered the run by. Dag and task configuration is generated from Airflow's own Dag serialization schema rather than hand-listed, so the Java attributes cannot drift from the Python semantics they mirror and new scalar keys appear after a schema sync. Only attributes written at the use site are applied, leaving Airflow's defaults in charge of everything unset. Design rationale is recorded in ADR-0008.
1 parent cb352ec commit 68fd401

30 files changed

Lines changed: 3327 additions & 177 deletions

File tree

‎.pre-commit-config.yaml‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,16 @@ repos:
295295
additional_dependencies: ['PyYAML>=6.0', 'rich>=13.6.0']
296296
pass_filenames: false
297297
require_serial: true
298+
- id: sync-java-sdk-dag-schema
299+
name: Sync Java SDK Dag serialization schema with airflow-core
300+
description: "Copy airflow-core's serialization schema when Java SDK's vendored dag-schema.json drifts"
301+
entry: ./java-sdk/gradlew -p ./java-sdk :sdk:syncDagSchema
302+
language: system
303+
pass_filenames: false
304+
files: >
305+
(?x)
306+
^airflow-core/src/airflow/serialization/schema\.json$|
307+
^java-sdk/sdk/schema/dag-schema\.json$
298308
- id: check-go-version-in-sync
299309
name: Check Go toolchain version is consistent across build files
300310
entry: ./scripts/ci/prek/check_go_version_in_sync.py
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
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+
# ADR-0008: TaskFlow-Style Dag DSL for Native Java Dags
21+
22+
## Status
23+
24+
Accepted
25+
26+
## Context
27+
28+
The Go SDK adopted a TaskFlow-style authoring API: `Dag.Task(fn, opts...)`
29+
returns a handle, `Inputs(handle)` both wires the dependency edge and feeds the
30+
upstream's return value into the downstream function's data parameter, and
31+
`TaskSpec` / `DagSpec` configuration structs are generated from the Dag
32+
serialization schema (`airflow-core/src/airflow/serialization/schema.json`).
33+
34+
The Java SDK could only implement *task bodies*. Everything else about the Dag
35+
had to come from a Python `@task.stub` file:
36+
37+
- `@Builder.Dag` / `@Builder.Task` carried only identity attributes; there was
38+
no way to express schedule, retries, queue, or any other Dag/task
39+
configuration.
40+
- The Java-side model kept only an `id -> task class` map: no dependency edges
41+
and no configuration, so a Java author could not describe a graph and there
42+
was nothing to serialize for native Dag support.
43+
44+
Python's TaskFlow shows the shape worth matching: calling tasks like functions
45+
*is* the graph declaration — `load(transform(extract()))`. Java annotations
46+
cannot change call semantics (invoking the real method would execute its body),
47+
so the call syntax has to target compile-time-generated twins.
48+
49+
## Decision
50+
51+
### Two authoring surfaces, one model
52+
53+
**Annotation surface** — task bodies are `@Builder.Task`-annotated methods;
54+
configuration lives in the annotations; the graph is wired by a static
55+
`@Wiring` method that receives a processor-generated *flow twin* class
56+
(`<Class>Ref`). Twin methods mirror the task methods: injectable parameters
57+
(`Client`, `Context`) are dropped, data parameters become `In<T>`-typed
58+
inputs, and the return value becomes a `TaskRef<T>` (which extends
59+
`In<T>`). The call graph is the task graph:
60+
61+
```java
62+
@Builder.Dag(id = "java_etl", schedule = "@daily")
63+
public class EtlPipeline {
64+
65+
@Builder.Task(id = "extract", retries = 2)
66+
public ExtractResult extract(Client client) { ... }
67+
68+
@Builder.Task
69+
public TransformResult transform(Client client, ExtractResult in) { ... }
70+
71+
@Builder.Task
72+
public void load(Context ctx, TransformResult in) { ... }
73+
74+
@Builder.Task
75+
public void score(double threshold) { ... }
76+
77+
@Wiring
78+
static void depends(EtlPipelineRef f) {
79+
f.load(f.transform(f.extract()));
80+
f.score(In.value(0.5)); // inline literal input
81+
}
82+
}
83+
```
84+
85+
There is deliberately **no id-based wiring mode** and no edge-declaring API
86+
on the handles: the wiring calls, and only they, define the graph. A twin call
87+
registers the task, so a `@Builder.Task` method never invoked in the wiring
88+
method is an error (checked when the generated `build()` runs, at Dag-parse
89+
time).
90+
91+
The wiring method is **optional**: a `@Builder.Dag` class without one registers
92+
every task with no Java-side edges. That is the shape for stub-backed tasks —
93+
the Python Dag file owns the graph and the supervisor delivers each call-site
94+
argument at run time — so those classes carry no `@Wiring`, no configuration
95+
attributes, and no Java-side dependencies at all.
96+
97+
**Interface surface** — plain-class task definitions registered as first-class
98+
`TaskDef` objects, with fluent schema-validated `.config(key, value)` calls
99+
and object-reference edges:
100+
101+
```java
102+
var extract = new TaskDef("extract", Extract.class).config("retries", 2);
103+
var transform = new TaskDef("transform", Transform.class).dependsOn(extract);
104+
105+
var dag = new DagDef("java_etl")
106+
.config("schedule", "@daily")
107+
.addTask(extract)
108+
.addTask(transform);
109+
```
110+
111+
A `TaskDef` belongs to at most one Dag. Both surfaces flow into the same
112+
model: schema-keyed configuration maps, `TaskDef` dependency edges, and
113+
recorded parameter inputs on `DagDef`/`TaskDef`.
114+
115+
### One package, one wildcard import
116+
117+
The whole user-facing surface — the `Builder.Dag` / `Builder.Task`
118+
annotations, `@Wiring`, the flow types (`In`, `TaskRef`), and the runtime
119+
types (`DagDef`, `TaskDef`, `Task`, `Client`, `Context`, `Bundle`, ...) —
120+
lives in `org.apache.airflow.sdk`, so every example starts with a single
121+
`import org.apache.airflow.sdk.*`. There is no separate `dsl` package.
122+
123+
Two top-level types cannot share a fully-qualified name, and `Dag` / `Task`
124+
are the names both the annotations and the runtime types want. Keeping the
125+
annotations **nested** in `Builder` resolves that without renaming anything:
126+
`Builder.Dag` and `Builder.Task` read as the annotations they are (they drive
127+
the `*Builder` codegen), the task-implementation interface keeps the plain
128+
name `Task`, and the Dag model is `DagDef`, symmetric with `TaskDef` — both
129+
are the definition objects of the interface API. `@Wiring` needs no
130+
qualification because nothing else claims that name.
131+
132+
By convention the `@Wiring` method sits at the end of the Dag class, after
133+
the task methods it wires — read the tasks first, then the graph.
134+
135+
### Wiring is type-checked by javac itself
136+
137+
Twin input types make the graph checks ordinary Java type checking rather
138+
than bespoke processor analysis:
139+
140+
- numeric parameters accept any numeric upstream (`In<? extends Number>`,
141+
widened or narrowed at run time by the shared decoder);
142+
- `Object`, raw `Map`, and raw `List` parameters accept any upstream
143+
(`In<?>`, decoded loosely at run time);
144+
- everything else accepts covariant matches of the declared type
145+
(`In<? extends T>`).
146+
147+
Unknown upstreams are unrepresentable (a handle only exists once its task is
148+
registered), cycles are unconstructible in call syntax, and type mismatches
149+
are javac errors at the twin call site. The interface API's `dependsOn` can
150+
still express a cycle, so `Bundle` construction validates acyclicity (and
151+
that every referenced upstream is registered in the same Dag).
152+
153+
### Runtime bindings win; wiring is the fallback
154+
155+
Data parameters resolve by **position**, never by parameter name: Java call
156+
syntax is positional (no kwargs) and Java parameter names are not API — an
157+
IDE rename must not change binding behaviour. This matches the Go SDK's
158+
flat-parameter contract.
159+
160+
When the supervisor delivered `arg_bindings` for the run, the binding at the
161+
parameter's position wins over anything the `@Wiring` method declared: for a
162+
stub task the Python call site *is* the graph the scheduler ordered the run
163+
by, so the Java class must not be able to disagree with it. The wiring-
164+
recorded inputs are the fallback, used when no bindings arrived — which is
165+
exactly the native-Dag case, where no Python call site exists. A `TaskRef`
166+
input then resolves to the upstream's return-value XCom and a literal input
167+
to its value. Keyword arguments still bind by name through a `TaskInput`
168+
bundle; the generated code branches on whether bindings arrived, because
169+
bindings fill the bundle field by field while the wiring fallback decodes the
170+
bundle wholesale from its single wired input.
171+
172+
### Generated from the serialization schema
173+
174+
The `Builder` class — the outer container plus its nested `Dag` and `Task`
175+
annotations, configuration attributes and all — and the `SchemaFields`
176+
validation table are generated at build time from a vendored copy of the Dag
177+
serialization schema (`sdk/schema/dag-schema.json`, kept in sync with
178+
airflow-core by the `sync-java-sdk-dag-schema` prek hook), mirroring the
179+
supervisor-schema → jsonschema2pojo pipeline that already exists. Generating
180+
the whole class rather than merging generated attributes into a hand-written
181+
one keeps a single definition of `Builder`; `id` (and `to` on `Dag`) stay the
182+
leading structural attributes, and generation fails if a schema key ever
183+
camel-cases onto one of them.
184+
185+
Field selection mirrors the Go SDK's `TaskSpec` generator: scalar properties
186+
only, serializer-owned keys skipped (`_`-prefixed, schema-required,
187+
`has_on_*`), a documented exclusion list for Python-only concerns that fails
188+
generation when it goes stale, and a hand-curated Dag-level allowlist matching
189+
Go's `DagSpec`. `schedule` is a virtual key that the future serializer maps to
190+
the schema's `timetable` object.
191+
192+
Temporal attributes are ISO-8601 strings in annotations (validated by the
193+
processor at compile time) and `java.time.Duration` /
194+
`java.time.OffsetDateTime` values in `.config` calls.
195+
196+
### Explicit-only lowering, single-sourced dag id
197+
198+
The processor lowers **only attributes written at the use site** into
199+
`DagDef.config(...)` / `TaskDef.config(...)` calls (via
200+
`AnnotationMirror.getElementValues`). Annotation defaults mirror schema
201+
defaults but are never emitted, so the serializer's omit-if-default semantics
202+
stay intact and the scheduler's own defaults win for everything unset.
203+
204+
Because this written-vs-defaulted distinction only exists at compile time,
205+
there is no reflective `new DagDef(SomeClass.class)` constructor. Instead the
206+
generated builder exposes `DAG_ID` and a `dag()` factory (Dag-level config
207+
only, no tasks) alongside `build()`, so the dag id is never restated in user
208+
code.
209+
210+
### No annotation on data parameters
211+
212+
Data parameters need no annotation at all: anything that is not an injectable
213+
type (`Client`, `Context`) is a data parameter, bound in declaration order.
214+
Naming an upstream in an annotation would duplicate what the wiring — or the
215+
Python call site — already declares. `Client.getXCom` / `Client.setXCom`
216+
remain for imperative access.
217+
218+
## Consequences
219+
220+
- One concept, one name: bindings are *args*/*inputs* across the wire
221+
contract, the Go SDK, and the Java surface; the Java graph is declared the
222+
way Python TaskFlow declares it, but type-checked at compile time.
223+
- Dependency edges, configuration, and parameter inputs now exist in the
224+
Java-side model, which is the prerequisite for emitting
225+
DagSerialization-v3 JSON for native Java Dags.
226+
- Config typos fail at compile time (annotations) or Dag-parse time
227+
(`.config`), never silently. Wiring mistakes fail at compile time (type
228+
mismatch, unknown handle) or Dag-parse time (unregistered task, `dependsOn`
229+
cycle), never at task run time.
230+
- An annotation Dag class that owns its graph writes a `@Wiring` method —
231+
the same posture as the Go SDK, trading a few lines for one wiring story
232+
instead of two. Stub-backed classes omit it and stay registration-only.
233+
- The `@Wiring` method references the generated `<Class>Ref` twin, so IDEs
234+
show unresolved symbols until the first successful build (the standard
235+
Java codegen experience, as with Dagger or AutoValue).
236+
- New scalar schema keys show up automatically in the annotations and the
237+
validation table after a schema sync; removals fail the build until the
238+
exclusion rules are updated — the surface cannot drift silently.
239+
- Generated public API (annotation attributes) varies with the vendored
240+
schema version, exactly like the generated supervisor-schema models.

‎airflow-core/adr/lang-sdk/README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ bind core interfaces and apply to every language SDK, not just the Java SDK.
3333
- [ADR-0005](0005-coordinator-packaging.md): coordinator packaging, module layout, and registration.
3434
- [ADR-0006](0006-no-lang-sdk-source-display.md): no Lang-SDK source display for mixed-language (`@task.stub`) Dags.
3535
- [ADR-0007](0007-taskflow-across-language-boundary.md): TaskFlow across the language boundary — argument binding for Lang-SDK tasks.
36+
- [ADR-0008](0008-taskflow-dag-dsl.md): TaskFlow-style Dag DSL for native Java Dags.
3637

3738
Decisions specific to a single SDK stay next to that SDK — for example, the Go SDK's bundle-format
3839
decisions live in [`go-sdk/adr/`](../../../go-sdk/adr).

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

Lines changed: 94 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -160,10 +160,18 @@ Annotate a plain Java class and let the SDK generate the boilerplate at compile
160160
* - Annotation
161161
- Purpose
162162
* - ``@Builder.Dag(id = "...")``
163-
- Marks the class as a task container. The ``id`` must match the ``dag_id`` in the Python Dag.
163+
- Marks the class as a task container. For a stub-backed Dag the ``id`` must match the
164+
``dag_id`` in the Python Dag. Further attributes (``schedule``, ``description``, ``tags``,
165+
``catchup``, …) mirror the Dag serialization schema and configure the Dag itself; only
166+
attributes written explicitly are applied. See :ref:`java-sdk/native-dags`.
164167
* - ``@Builder.Task(id = "...")``
165-
- Marks a method as a task implementation. The ``id`` must match the ``@task.stub`` function
166-
name in the Python Dag. If ``id`` is omitted the method name is used.
168+
- Marks a method as a task implementation. For a stub-backed Dag the ``id`` must match the
169+
``@task.stub`` function name in the Python Dag. If ``id`` is omitted the method name is
170+
used. Further attributes (``retries``, ``queue``, ``retryDelay``, …) mirror the Dag
171+
serialization schema; only attributes written explicitly are applied.
172+
* - ``@Wiring``
173+
- Marks the static method that declares the task graph in Java, TaskFlow-style. Only needed
174+
for a Dag that has no Python stub file. See :ref:`java-sdk/native-dags`.
167175
* - ``TaskInput`` / ``@ArgName("...")``
168176
- Marks a class as a task's input bundle, so keyword arguments bind by name instead of by
169177
position: each public field receives the binding whose name matches it (the ``@ArgName``
@@ -205,7 +213,9 @@ Interface-based API
205213
~~~~~~~~~~~~~~~~~~~
206214

207215
Implement the ``Task`` interface directly for full control over how tasks are registered and how XComs are
208-
read. Each task is registered as a ``TaskDef`` on a ``DagDef``.
216+
read. Each task is registered as a ``TaskDef`` on a ``DagDef``; both carry a fluent
217+
``config(key, value)`` whose keys are Dag serialization schema property names, and ``TaskDef`` also
218+
carries ``dependsOn(...)`` for declaring edges between task definitions.
209219

210220
.. code-block:: java
211221
@@ -241,9 +251,12 @@ Register tasks manually in a ``BundleBuilder``:
241251
public class MyBundle implements BundleBuilder {
242252
@Override
243253
public Iterable<DagDef> getDags() {
254+
var fetch = new TaskDef("fetch", FetchTask.class).config("retries", 2);
255+
var process = new TaskDef("process", ProcessTask.class).dependsOn(fetch);
244256
var dag = new DagDef("my_dag")
245-
.addTask("fetch", FetchTask.class)
246-
.addTask("process", ProcessTask.class);
257+
.config("schedule", "@daily")
258+
.addTask(fetch)
259+
.addTask(process);
247260
return List.of(dag);
248261
}
249262
}
@@ -354,6 +367,81 @@ An ``InputTask`` whose type argument is not a concrete, supported ``TaskInput``
354367
``Bundle`` is built, rather than mid-run. Plain ``Task`` remains the right interface for a task the
355368
Dag file calls with no arguments.
356369

370+
.. _java-sdk/native-dags:
371+
372+
Native Java Dags
373+
----------------
374+
375+
A Dag can also be authored entirely in Java, with no Python stub file: the annotations (or the
376+
``TaskDef`` / ``DagDef`` objects) carry the configuration, and Java declares the graph.
377+
378+
Wiring the graph with ``@Wiring``
379+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
380+
381+
The annotation processor generates a ``<ClassName>Ref`` twin class whose methods mirror the
382+
``@Builder.Task`` methods: the injected ``Client`` and ``Context`` parameters are dropped, each data
383+
parameter takes an ``In<T>`` input, and the return value becomes a ``TaskRef<T>``. A static
384+
``@Wiring`` method receives the twin and calls it — calling a twin registers the task, and passing
385+
one twin's result into another feeds the upstream's output into the downstream's parameter *and*
386+
wires the dependency edge. The call graph is the task graph, and ``javac`` type-checks it:
387+
388+
.. code-block:: java
389+
390+
@Builder.Dag(
391+
id = "java_etl",
392+
schedule = "@daily",
393+
description = "Pure-Java Dag, no Python stub file",
394+
tags = {"example", "java-sdk"})
395+
public class EtlPipeline {
396+
397+
@Builder.Task(id = "extract", retries = 2)
398+
public long extract() {
399+
return 42L;
400+
}
401+
402+
@Builder.Task(id = "transform")
403+
public long transform(long extracted) {
404+
return extracted * 2;
405+
}
406+
407+
@Builder.Task(id = "load")
408+
public void load(long transformed) {
409+
// implement task logic
410+
}
411+
412+
@Wiring
413+
static void depends(EtlPipelineRef f) {
414+
f.load(f.transform(f.extract()));
415+
}
416+
}
417+
418+
Every ``@Builder.Task`` method must be invoked in the wiring method; a task the wiring missed fails
419+
at Dag-parse time. ``In.value(...)`` wires an inline literal where no upstream feeds a parameter.
420+
The wiring method is optional — a class without one registers every task with no Java-side edges,
421+
which is the shape for stub-backed tasks whose graph the Python Dag file owns.
422+
423+
.. note::
424+
425+
Runtime argument bindings win over Java-declared wiring. When the supervisor delivers bindings
426+
for a run (see :ref:`java-sdk/arg-binding`), the binding at a parameter's position is what the
427+
task receives, because for a stub task the Python call site is the graph the scheduler ordered
428+
the run by. Wired inputs are the fallback, which is what a native Java Dag always uses.
429+
430+
Configuration attributes
431+
~~~~~~~~~~~~~~~~~~~~~~~~
432+
433+
The ``@Builder.Dag`` and ``@Builder.Task`` configuration attributes, and the keys accepted by
434+
``DagDef.config`` and ``TaskDef.config``, are generated from Airflow's Dag serialization schema, so
435+
they carry the same names and types as their Python counterparts. Annotation attributes are
436+
``camelCase`` (``retryDelay``); ``config`` keys are the verbatim schema names (``"retry_delay"``).
437+
Only attributes written explicitly at the use site are applied, so Airflow's own defaults still
438+
apply to everything left out.
439+
440+
Durations and date-times are ISO-8601 strings in annotations (``retryDelay = "PT5M"``,
441+
``startDate = "2026-01-01T00:00:00Z"``, validated at compile time) and ``java.time.Duration`` /
442+
``java.time.OffsetDateTime`` values in ``config`` calls. An unknown key or a mismatched value type
443+
fails the build (annotations) or Dag parsing (``config``).
444+
357445
.. _java-sdk/logging:
358446

359447
Logging

0 commit comments

Comments
 (0)