| <!-- |
| 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. |
| --> |
| |
| # ADR-0002: Native Java Dag — Interface Design |
| |
| ## Status |
| |
| Proposed |
| |
| ## Context |
| |
| A Dag authored with no Python stub file has no `@task.stub` call site to declare its graph, so |
| Java itself must express the graph, the Dag/task configuration, and the task bodies. This ADR is |
| scoped to what that Java call site looks like for a user. It also settles the injectable |
| `client`/`context` question shared with [ADR-0001](0001-mixed-lang-dag-interface.md): they are |
| **injected as method arguments**, not exposed through getters. It shares the protocol substrate |
| (the argument-binding spec) with |
| [`airflow-core/adr/lang-sdk/0007-taskflow-across-language-boundary.md`](../../airflow-core/adr/lang-sdk/0007-taskflow-across-language-boundary.md). |
| |
| ## Decision |
| |
| ### Annotation based |
| |
| `Client` and `Context` are injected as **method arguments**, exactly as on the mixed-language |
| surface ([ADR-0001](0001-mixed-lang-dag-interface.md)) — there are no getters and no SDK base class |
| to extend. A task method's signature is its injected arguments, if any, followed by its data: |
| |
| ```java |
| @Builder.Dag(id = "java_etl", schedule = "@daily") |
| public class EtlPipeline { // extends nothing of ours; your own base class stays free |
| |
| @Builder.Task(id = "extract", retries = 2) |
| public long extract(Client client) { |
| return ((Number) client.getVariable("row_count")).longValue(); |
| } |
| |
| @Builder.Task(id = "transform") |
| public long transform(Client client, Context context, long extracted, double threshold) { |
| client.setXCom("scaled_in", context.runId); |
| return (long) (extracted * threshold); |
| } |
| |
| @Builder.Task(id = "load") |
| public void load(Context context, long transformed) { |
| log.log(INFO, "Loaded {0} for run {1}", transformed, context.runId); |
| } |
| |
| @Builder.Task(id = "audit") |
| public void audit(Client client) { /* side effect only, no data in or out */ } |
| |
| @Builder.Task(id = "notify") |
| public void notify(Client client) { /* side effect only, no data in or out */ } |
| |
| @Builder.Deps |
| static class Wiring implements EtlPipelineDeps { |
| void depends() { |
| // TaskFlow (data) edges: implied by passing a TaskRef; a constant is wrapped with lit(...) |
| var rows = extract(); |
| var scaled = transform(rows, lit(0.9)); |
| var loaded = load(scaled); |
| |
| // non-TaskFlow (ordering-only) edges: sequence with no data flowing |
| rows.then(audit()); // extract >> audit |
| Flow.of(loaded, audit()).then(notify()); // [load, audit] >> notify |
| } |
| } |
| |
| public static void main(String[] args) { |
| Bundle bundle = new Bundle().register(EtlPipeline.class); |
| Server.create(args).serve(bundle); |
| } |
| } |
| ``` |
| |
| Because the injected arguments are real parameters, the wiring expression cannot call the task |
| methods directly — `extract(client)` would demand a `Client` the wiring has no business supplying. |
| So the processor generates one **wiring view** per Dag: the interface `<Dag>Deps` (here |
| `EtlPipelineDeps`), one default method per task with the injected arguments stripped, each data |
| argument lifted to `Arg<T>`, and the return lifted to `TaskRef<T>`: |
| |
| ```java |
| // Generated by the Airflow Java SDK annotation processor. Do not edit. |
| interface EtlPipelineDeps extends Deps { // Deps: the shared base that nests Flow |
| default TaskRef<Long> extract() { return Flow.node("extract"); } |
| default TaskRef<Long> transform(Arg<Long> extracted, Arg<Double> threshold) { return Flow.call("transform", extracted, threshold); } |
| default TaskRef<Void> load(Arg<Long> transformed) { return Flow.call("load", transformed); } |
| default TaskRef<Void> audit() { return Flow.node("audit"); } |
| default TaskRef<Void> notify() { return Flow.node("notify"); } |
| } |
| ``` |
| |
| The `@Builder.Deps` class implements this interface, so `depends()` composes the wiring methods and |
| `javac` type-checks the graph — `extract()` yields a `TaskRef<Long>`, which is an `Arg<Long>` for |
| `transform`, and so on. The class only *implements an interface*, so its `extends` stays free, and |
| being `static` it cannot reach the outer class's real task methods — wiring can only touch the |
| generated views. |
| |
| `@Builder.Deps` is read by executing it once, in **recording mode**, at registration: each wiring |
| call records a memoized node (so `extract()` is the same `TaskRef` everywhere), passing a `TaskRef` |
| into another call records a data edge, and the ordering verbs below record ordering edges. No task |
| body runs, and — because a `TaskRef` carries the node's identity as an object reference — the |
| processor never reads the method's syntax tree, so this works under any compiler, not only javac. |
| |
| The result is the Dag below, equivalent to the interface-based form written by hand: |
| |
| ```java |
| // Generated by the Airflow Java SDK annotation processor. Do not edit. |
| public final class EtlPipeline_Dag { |
| |
| public static Dag dag() { |
| Dag dag = new Dag("java_etl").config("schedule", "@daily"); |
| TaskRef extract = dag.task("extract", Extract.class).config("retries", 2); |
| TaskRef transform = dag.task("transform", Transform.class); |
| TaskRef load = dag.task("load", Load.class); |
| TaskRef audit = dag.task("audit", Audit.class); |
| TaskRef notify = dag.task("notify", Notify.class); |
| extract.then(transform).then(load); // data path |
| extract.then(audit); // ordering-only edge |
| Flow.of(load, audit).then(notify); // ordering-only edge |
| return dag; |
| } |
| |
| public static final class Transform implements Task { |
| @Override |
| public void execute(Context context, Client client) throws Exception { |
| TaskArgs args = TaskArgs.of(context); |
| long extracted = args.require(0, Long.class); |
| double threshold = 0.9; // baked from lit(0.9) at Dag-build time |
| client.setXCom(new EtlPipeline().transform(client, context, extracted, threshold)); |
| } |
| } |
| |
| // Extract, Load, Audit, and Notify follow the same shape. |
| } |
| ``` |
| |
| The injected arguments are passed straight into the user method, and the data arguments bind by |
| position through the same internal `TaskArgs` the mixed-language surface uses |
| ([ADR-0001](0001-mixed-lang-dag-interface.md)) — no getters, no `bind()`, nothing ambient. |
| |
| **Literals.** A data argument is an `Arg<T>`, which a `TaskRef` satisfies; a constant is wrapped |
| with `lit(...)` — `transform(extract(), lit(0.9))` — and recorded as a baked value with no edge. |
| Wrapping is required because a bare `Integer` cannot implement `Arg`; boxed types only, no |
| primitives. |
| |
| ### Non-data dependencies |
| |
| A data edge comes for free from passing a `TaskRef` into another wiring method. A dependency |
| where no data flows, similar to Python's `a >> b`, is expressed with `then`, a variadic verb |
| that every `TaskRef` carries. It lives on a small `Flow` interface, and a `TaskRef` is a `Flow` |
| of one (`interface TaskRef<T> extends Arg<T>, Flow`): |
| |
| ```java |
| a.then(b); // a >> b |
| a.then(b, c); // a >> [b, c] |
| ``` |
| |
| `then` returns the **new frontier** (the set it just pointed at), the way `>>` evaluates to its |
| right operand, so a chain walks through a fan: |
| |
| ```java |
| a.then(b, c).then(d); // a >> [b, c] >> d (a->b, a->c, then b->d, c->d) |
| ``` |
| |
| The one thing `then` cannot do is start from a *set*: Java can't overload `>>` the way Python |
| does, and there is no list literal to call `.then` on, so `Flow.of` opens a chain from one: |
| |
| ```java |
| Flow.of(a, b).then(c); // [a, b] >> c |
| Flow.of(a, b).then(c, d); // [a, b] >> [c, d] |
| ``` |
| |
| Both edge kinds compile to the same `then(...)` in the generated Dag above. The only difference is |
| that a data edge also binds an argument while an ordering-only edge binds nothing. |
| |
| ### Interface based |
| |
| ```java |
| Dag dag = new Dag("java_etl").config("schedule", "@daily"); |
| |
| TaskRef extract = dag.task("extract", Extract.class).config("retries", 2); |
| TaskRef transform = dag.task("transform", Transform.class); |
| TaskRef load = dag.task("load", Load.class); |
| TaskRef notify = dag.task("notify", Notify.class); |
| |
| extract.then(transform).then(load).then(notify); |
| ``` |
| |
| `dag.task(id, class)` registers the task as it creates it and hands back a `TaskRef`, so there is no |
| second `addTask(...)` call to forget. `then` is the same variadic edge verb as the |
| annotation surface — Java's spelling of Python's `>>` — and `.config(key, value)` carries |
| Dag and task configuration. This surface wires edges through object references and reads no syntax |
| tree, so it works under any toolchain and is exactly what the annotation surface's recording |
| produces. |
| |
| ### Registering and serving |
| |
| Both surfaces produce a `Dag`, and a `Bundle` is what the server serves — create it, register, serve: |
| |
| ```java |
| public static void main(String[] args) { |
| Bundle bundle = new Bundle() |
| .register(EtlPipeline.class) // annotation based: the user's own annotated class |
| .register(dag); // interface based: the Dag built above |
| |
| Server.create(args).serve(bundle); |
| } |
| ``` |
| |
| An annotated Dag registers as the class the user wrote — the same class the annotations are on — so |
| there is no second name to learn or keep in sync. |
| |
| Nothing is constructed at registration. Task classes are instantiated per task-instance |
| run ([ADR-0001](0001-mixed-lang-dag-interface.md)), and the Dag definition itself is read once, at |
| registration, without running a task body. |
| |
| One bundle carries native Dags and mixed-language task handlers alike |
| ([ADR-0001](0001-mixed-lang-dag-interface.md)), so one process serves both. |
| |
| ## Alternatives |
| |
| - **Getters for `Client`/`Context`** (`getClient()`/`getContext()` on an SDK base class the Dag |
| extends), so a task method takes only its data and `depends()` composes the real signatures |
| directly, with no wiring view. Rejected: the team does not want ambient getters — they invite the |
| "reach for the context anywhere" pattern we already avoid in Python — and a base class spends the |
| Dag class's single `extends` on the SDK, so the user can no longer extend their own. Injected |
| arguments keep the getter out of the language and the Dag class's inheritance free; the wiring view |
| is the price, and it is generated rather than written. |
| |
| - **Reading the `@Wiring` body's syntax tree** instead of executing it. With getters the task |
| methods are data-only, so `depends()` composes real signatures and a processor could walk the tree |
| and emit one edge per nested call. Rejected on two counts: it depends on |
| `com.sun.source.util.Trees`, javac's tree model, so it does not run under a compiler with its own |
| (ecj); and it must recover identity syntactically — a result held in a local |
| (`long e = extract(); fanin(e, ...)`) is an identifier, not a call, so the walker has to resolve it |
| through the initializer and bail on anything it cannot attribute. Recording sidesteps both: nothing |
| parses source, and a reused local just works. |
| |
| - **Capturing the graph by overriding task methods in a generated subclass**, so an inherited |
| `depends()` calls the overrides. Rejected because a raw return (`long`) carries no identity, so a |
| result stored in a local and used twice cannot be attributed to its producer. Recording avoids this |
| precisely by having the wiring view return `TaskRef` rather than the task's real return type — |
| identity travels with the handle, so this is not the same mechanism as the subclass capture. |
| |
| - **Naming the type `Flow`, over `Order` and `Chain`.** The type every `TaskRef` carries, and that |
| `Flow.of` returns, needed a name. `Chain` was rejected because Airflow's Python `chain()` is a |
| *sequential* helper (`chain(a, b)` means `a >> b`), so `Chain.of(a, b)` would read as the edge |
| `a -> b` rather than the parallel set `{a, b}`. `Order` reads well for pure sequencing, but misnames |
| a group that can carry data: a chain like `Flow.of(a(), b(c())).then(d(e()))` also records the data |
| edges `c -> b` and `e -> d`, so the group is tasks *in the flow*, not an ordering. It shares a |
| name with `java.util.concurrent.Flow`, but nesting defuses that: `Flow` is a member type of the |
| shared `Deps` base every `<Dag>Deps` extends, so wiring code inherits it by simple name and never |
| imports it (see Implementation Notes). |
| |
| - **Only `then`, not Python's fuller set of relation helpers.** Python offers `>>` and `<<`, |
| `chain()`, etc. This surface has only one forward verb, plus `Flow.of`. A single left-to-right verb reads |
| consistently, and is less to learn. Nothing is lost; the reverse direction and the shorthands can still be |
| properly expressed. |
| |
| ## Consequences |
| |
| - Two authoring surfaces (annotation, interface) flow into the same `Dag`/`TaskRef` model, and the |
| annotation surface's recording produces exactly the interface-based calls a user could have |
| written. |
| - `Client`/`Context` are injected as arguments on both the native and mixed-language surfaces, so a |
| single injection rule spans the SDK and no getter or SDK base class exists. |
| - The wiring view (`<Dag>Deps`) is generated, so the graph is real, type-checked Java — but the |
| interface name resolves in an IDE only after the first build that runs the processor. This is the |
| standard annotation-processing tradeoff (Dagger, AutoValue, MapStruct) and needs the processor |
| wired into the build so generated sources are indexed. |
| - Recording reads no syntax tree, so the annotation surface works under any compiler, not only javac. |
| - A task method keeps its real signature — injected arguments then data — and the only generated |
| user-facing type is the wiring-view interface it implements: no twin class, no `In<T>`, no getters, |
| no `bind()`. |
| - Native tasks keep `@Builder.Task`, while the mixed-language surface uses |
| `@Builder.TaskHandler` ([ADR-0001](0001-mixed-lang-dag-interface.md)), so an annotation names which |
| of the two a method is. |
| |
| ## Appendix: Implementation Notes |
| |
| - The processor generates, per Dag, the `<Dag>Deps` wiring-view interface and a registrar. The |
| registrar instantiates the `@Builder.Deps` class, installs a recorder, calls `depends()`, collects |
| the graph, and validates it (no cycles, no self-edges), reporting a bad edge on its own source line |
| through `Messager`. It reads no syntax tree. |
| - The wiring view strips a parameter only when its type is `Client` or `Context`; every other |
| parameter becomes an `Arg<T>` in declared order, and the return becomes `TaskRef<T>` |
| (`TaskRef<Void>` for `void`). |
| - **The nested `@Builder.Deps` class is required, not stylistic.** The Dag class holds |
| `long extract(Client)` and the wiring view holds `TaskRef<Long> extract()` — two methods that would |
| be an overload clash in one class. A separate type carries the view, and it `implements` (never |
| `extends`) so the Dag class keeps its single inheritance slot. |
| - Wiring methods are memoized by task id, so `extract()` returns the same `TaskRef` wherever it is |
| called; that identity is what lets a data edge and a later ordering edge refer to one node. |
| - **`then` is variadic and returns the new frontier**, so `a.then(b, c).then(d)` walks through a |
| fan, and linear runs, fan-outs, and fan-ins all fall out of the one verb. `Flow.of(...)` is the |
| only entry point beyond it — it opens a chain from a set, which `then` cannot. A data edge never |
| needs any of this — it is implied by passing a `TaskRef`. |
| - **`Flow` is a nested member of a shared base, not a top-level type.** Every generated `<Dag>Deps` |
| extends one library interface, `Deps`, that declares the nested interface `Flow`. Member types are |
| inherited, so a `@Builder.Deps` class implementing `<Dag>Deps` names `Flow` (as in `Flow.of(...)`) |
| by simple name with no import — so there is nothing for `java.util.concurrent.Flow` to clash with. |
| A single-type-import does not override this: a visible member type outranks a single-type-import in |
| simple-name resolution (JLS §6.5.5.1; and §6.4.1, whose single-type-import shadowing list covers |
| only top-level types in other units and on-demand imports, not member types), so even an explicit |
| `import java.util.concurrent.Flow` in the same file still resolves `Flow` to the inherited member. |
| The imperative interface-based surface does not extend `Deps`, so it qualifies the factory as |
| `Deps.Flow.of(...)` — or, since it always has named refs, skips it entirely, writing a fan-in as |
| `x.then(z); y.then(z)`. |
| - **`Arg<T>` is the data-argument type in a wiring method**; `TaskRef<T> extends Arg<T>`, and |
| `lit(value)` wraps a boxed constant. A `TaskRef` argument records an edge; a `lit(...)` records a |
| baked value with none. Because both flow through `Arg` and the recorder distinguishes them by type, |
| there is no ambiguity even at an `Object`-typed position. |
| - **At run time the getters are gone.** The generated `Task` passes the injected `Client`/`Context` |
| straight into the user method and binds data arguments by position through `TaskArgs` |
| ([ADR-0001](0001-mixed-lang-dag-interface.md)) — the same conversion-not-cast path, including the |
| `TypeReference<T>` overload for generics. Nothing is ambient, so the SDK's Java 11 pin (no |
| `ScopedValue`) does not matter here. |
| - **`dag.task(...)`, `.config(...)`, and `.then(...)` are proposed additions.** |
| Today's shipped `Dag` has only `addTask(id, definition)`, which returns the Dag rather than a ref; |
| `.config(...)` is keyed to the Dag serialization schema. `dag.task(...)` is a factory method, not a |
| constructor — Java spells qualified inner-class creation `dag.new TaskRef(...)`, which reads as |
| compiler trivia rather than as a Dag definition. |
| - **`register` is one overloaded verb**: `register(Class<?>)` for an annotated Dag class, |
| `register(Dag)` for a built one, and `register(dag, task, Class<? extends Task>)` for a task |
| handler ([ADR-0001](0001-mixed-lang-dag-interface.md)). Today a bundle is built from an |
| `Iterable<Dag>` through `BundleBuilder.getDags()`. |
| - **Registration holds classes, never instances.** `Dag.addTask(id, Class<? extends Task>)` already |
| states that Airflow "instantiates the class via its no-argument constructor, then calls `execute` |
| once per task-instance run", so an instance exists only for one task-instance run — never across |
| runs, and never at registration. |
| - Testing a task is testing an ordinary method with a real return type whose injected arguments are |
| passed in directly — no base class to subclass, no getters to override, no `In<T>` to unwrap. |