* Java SDK: Declare a Dag's task graph with @Builder.Deps An annotated Dag class could name its tasks but not say how they connect, so a Dag whose annotations carried everything else still needed a Python Dag file for its graph. Declaring the graph by calling the tasks lets javac check that each task is fed something its parameter accepts. Bundle.register(Class) now also takes the @Builder.Dag class itself and registers the Dag its generated builder builds, so there is still only one name to keep in sync. * Java SDK: Quote wired task ids as Java string literals * Java SDK: Validate the @Builder.Deps class at compile time * Java SDK: Reject task method names that clash with the wiring view * Java SDK: Type a wired numeric input by its parameter's own type * Java SDK: Register both the Dag and handlers of a class, unwrapping builder failures * Java SDK: Reject a raw null wiring argument and test the recorder's guards * Java SDK: Reword the native-Dag docs without em-dashes * Java SDK: Drop Bundle tests that repeat existing coverage * Java SDK: Require a @Builder.Deps class on every @Builder.Dag class * Java SDK: Back the k8s test's stub tasks with task handlers * Java SDK: Reject overloaded task and task-handler methods * Java SDK: Fix the Dag examples a required wiring class invalidates
19 KiB
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: 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.
Decision
Annotation based
Client and Context are injected as method arguments, exactly as on the mixed-language
surface (ADR-0001) — 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:
@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")
// "notify" is Object.notify, so the method takes another name and keeps the task id.
public void alert(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(alert()); // [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>:
// 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> alert() { 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:
// 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, client, 1);
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) — 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):
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:
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:
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
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:
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), 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), 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 anddepends()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 singleextendson 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
@Wiringbody's syntax tree instead of executing it. With getters the task methods are data-only, sodepends()composes real signatures and a processor could walk the tree and emit one edge per nested call. Rejected on two counts: it depends oncom.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 returnTaskRefrather 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, overOrderandChain. The type everyTaskRefcarries, and thatFlow.ofreturns, needed a name.Chainwas rejected because Airflow's Pythonchain()is a sequential helper (chain(a, b)meansa >> b), soChain.of(a, b)would read as the edgea -> brather than the parallel set{a, b}.Orderreads well for pure sequencing, but misnames a group that can carry data: a chain likeFlow.of(a(), b(c())).then(d(e()))also records the data edgesc -> bande -> d, so the group is tasks in the flow, not an ordering. It shares a name withjava.util.concurrent.Flow, but nesting defuses that:Flowis a member type of the sharedDepsbase every<Dag>Depsextends, 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, plusFlow.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/TaskRefmodel, and the annotation surface's recording produces exactly the interface-based calls a user could have written. Client/Contextare 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, nobind(). - Native tasks keep
@Builder.Task, while the mixed-language surface uses@Builder.TaskHandler(ADR-0001), so an annotation names which of the two a method is.
Appendix: Implementation Notes
- The processor generates, per Dag, the
<Dag>Depswiring-view interface and a registrar. The registrar instantiates the@Builder.Depsclass, installs a recorder, callsdepends(), collects the graph, and validates it (no cycles, no self-edges), reporting a bad edge on its own source line throughMessager. It reads no syntax tree. - The wiring view strips a parameter only when its type is
ClientorContext; every other parameter becomes anArg<T>in declared order, and the return becomesTaskRef<T>(TaskRef<Void>forvoid). - The nested
@Builder.Depsclass is required, not stylistic. The Dag class holdslong extract(Client)and the wiring view holdsTaskRef<Long> extract()— two methods that would be an overload clash in one class. A separate type carries the view, and itimplements(neverextends) so the Dag class keeps its single inheritance slot. - Wiring methods are memoized by task id, so
extract()returns the sameTaskRefwherever it is called; that identity is what lets a data edge and a later ordering edge refer to one node. thenis variadic and returns the new frontier, soa.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, whichthencannot. A data edge never needs any of this — it is implied by passing aTaskRef.Flowis a nested member of a shared base, not a top-level type. Every generated<Dag>Depsextends one library interface,Deps, that declares the nested interfaceFlow. Member types are inherited, so a@Builder.Depsclass implementing<Dag>DepsnamesFlow(as inFlow.of(...)) by simple name with no import — so there is nothing forjava.util.concurrent.Flowto 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 explicitimport java.util.concurrent.Flowin the same file still resolvesFlowto the inherited member. The imperative interface-based surface does not extendDeps, so it qualifies the factory asDeps.Flow.of(...)— or, since it always has named refs, skips it entirely, writing a fan-in asx.then(z); y.then(z).Arg<T>is the data-argument type in a wiring method;TaskRef<T> extends Arg<T>, andlit(value)wraps a boxed constant. ATaskRefargument records an edge; alit(...)records a baked value with none. Because both flow throughArgand the recorder distinguishes them by type, there is no ambiguity even at anObject-typed position.- At run time the getters are gone. The generated
Taskpasses the injectedClient/Contextstraight into the user method and binds data arguments by position throughTaskArgs(ADR-0001) — the same conversion-not-cast path, including theTypeReference<T>overload for generics. Nothing is ambient, so the SDK's Java 11 pin (noScopedValue) does not matter here. dag.task(...),.config(...), and.then(...)are proposed additions. Today's shippedDaghas onlyaddTask(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 creationdag.new TaskRef(...), which reads as compiler trivia rather than as a Dag definition.registeris one overloaded verb:register(Class<?>)for an annotated Dag class,register(Dag)for a built one, andregister(dag, task, Class<? extends Task>)for a task handler (ADR-0001). Today a bundle is built from anIterable<Dag>throughBundleBuilder.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 callsexecuteonce 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.